Forms & Validation
Overview
The reliable SaaS form stack is react-hook-form (state + performance) + zod (schema validation) + shadcn/ui Form (accessible field wiring). Define the schema once, infer the TypeScript type from it, and let the form derive validation, errors, and types from a single source of truth.
When to use
- Any form: auth, settings, create/edit dialogs, checkout.
- Inputs need validation, inline errors, or async submission.
- You're manually wiring
useStateper field (stop — use RHF).
The pattern
tsx"use client"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; const schema = z.object({ email: z.string().email("Enter a valid email"), password: z.string().min(8, "At least 8 characters"), }); type Values = z.infer<typeof schema>; export function SignInForm() { const form = useForm<Values>({ resolver: zodResolver(schema), defaultValues: { email: "", password: "" }, }); async function onSubmit(values: Values) { const res = await signIn(values); if (!res.ok) { form.setError("password", { message: "Invalid email or password" }); return; } } return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate> <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>Email</FormLabel> <FormControl><Input type="email" autoComplete="email" {...field} /></FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="password" render={({ field }) => ( <FormItem> <FormLabel>Password</FormLabel> <FormControl><Input type="password" autoComplete="current-password" {...field} /></FormControl> <FormMessage /> </FormItem> )} /> <Button type="submit" disabled={form.formState.isSubmitting} className="w-full"> {form.formState.isSubmitting ? "Signing in…" : "Sign in"} </Button> </form> </Form> ); }
shadcn/ui Form auto-wires id, aria-invalid, aria-describedby, and <FormMessage> to each field — accessible errors with no manual plumbing.
Why this stack
- One schema → types + validation.
z.inferkeeps TS and runtime in sync. - Reuse on the server. Run the same zod schema in your API/action; never trust the client.
- Performance. RHF uses uncontrolled inputs + refs, so typing doesn't re-render the whole form.
Submission UX
- Disable submit + show a spinner label while
isSubmitting. - Map server/field errors with
form.setError(name, …); show form-level errors in anAlert. - On success: toast, then redirect or reset (
form.reset()).
Pitfalls
useStateper field — re-renders, more code, manual error wiring.- Always provide
defaultValues— omitting them makes inputs uncontrolled and React warns/loses data. - Validating only on the client — re-run the zod schema server-side.
- No submitting state — users double-submit; disable the button.
- Missing
autoComplete/type— hurts autofill and mobile keyboards. type="submit"missing — Enter won't submit the form.
Hand-off
A validated, accessible form that auth-screens, settings-pages, and onboarding-flows reuse; pair errors with notifications-and-toasts for success feedback.


