Forms & Validation
Forms use controlled React inputs with Zod for validation. There's no form library – the patterns are simple enough that a direct approach keeps things explicit.
Basic Pattern
An application-owned form keeps input values in useState and calls a mutation hook from a query module. useCreateProject below comes from Add a tRPC Procedure – no tRPC mutation ships in the starter, so build it first:
import { useCreateProject } from "@/lib/queries/project";
import { Button, Input, Label } from "@repo/ui";
import { useId, useState } from "react";
function CreateProjectForm() {
const [name, setName] = useState("");
const nameId = useId();
const createProject = useCreateProject();
return (
<form
onSubmit={(e) => {
e.preventDefault();
// Clearing the input belongs to this form, not to every caller of the
// hook, so it rides on the call rather than the module's onSuccess.
createProject.mutate({ name }, { onSuccess: () => setName("") });
}}
>
<Label htmlFor={nameId}>Project name</Label>
<Input
id={nameId}
value={name}
onChange={(e) => setName(e.target.value)}
required
aria-invalid={Boolean(createProject.error)}
/>
{createProject.error && (
<p role="alert" className="text-sm text-destructive">
{createProject.error.message}
</p>
)}
<Button type="submit" disabled={createProject.isPending}>
{createProject.isPending ? "Creating..." : "Create"}
</Button>
</form>
);
}The failure arrives after submit, so role="alert" announces it – the same pattern as the create-organization form in apps/app/routes/(app)/members.tsx.
Zod Schema Sharing
Zod schemas are defined on tRPC procedures and can be shared with the frontend for search param validation or client-side checks. The login route uses a Zod schema with validateSearch to sanitize the returnTo param at parse time – see Routing > Search Params for the full example.
Auth Form
The auth form (apps/app/components/auth/auth-form.tsx) demonstrates a multi-step form pattern. It uses a state machine with three steps:
method → email → otp
↑ ↑ │
└────────┘ │
←───────┘The useAuthForm hook manages transitions between steps:
const VALID_TRANSITIONS: Record<AuthStep, AuthStep[]> = {
method: ["email"],
email: ["method", "otp"],
otp: ["email"],
};Each step renders conditionally based on the current state:
export function AuthForm({ mode = "login", onSuccess, returnTo }) {
const { step, email, isDisabled, error /* actions */ } = useAuthForm({
onSuccess,
mode,
});
return (
<div className="flex flex-col gap-6 w-full">
{error && (
<div
role="alert"
className="rounded-md bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</div>
)}
{step === "method" && <MethodSelection /* ... */ />}
{step === "email" && <EmailInput /* ... */ />}
{step === "otp" && <OtpStep /* ... */ />}
</div>
);
}Key design decisions in useAuthForm:
- Counter-based pending ops – handles overlapping child operations (e.g., a rapid double-click)
- Success guard (
hasSucceededRef) – prevents concurrent auth completion from multiple methods - Email normalization – trims whitespace and lowercases before API calls
- Error orthogonal to steps – errors can occur at any step and are displayed at the form level
Error Display
Errors are shown as alert boxes with role="alert" for screen reader announcements:
{
error && (
<div
role="alert"
className="rounded-md bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</div>
);
}For mutation errors, check mutation.error:
{
mutation.error && (
<div role="alert" className="text-sm text-destructive">
{mutation.error.message}
</div>
);
}Loading States
Coordinate disabled state across form elements to prevent double-submission:
// useAuthForm folds its own request and every child's into one flag
const isDisabled = isLoading || pendingOps > 0;Apply to all interactive elements:
<Input disabled={isDisabled} />
<Button type="submit" disabled={isDisabled || !email.trim()}>
Continue
</Button>For mutations, use isPending from the mutation object:
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Saving..." : "Save"}
</Button>Post-Submission
After successful form submission, the caller handles cache invalidation and navigation – not the form itself:
// apps/app/routes/(auth)/login.tsx
async function handleSuccess() {
await revalidateSession(queryClient, router);
await router.navigate({ to: search.returnTo ?? "/" });
}
<AuthForm mode="login" onSuccess={handleSuccess} returnTo={search.returnTo} />;This keeps the form reusable – AuthForm backs both the login and signup pages because the caller controls what happens after success.