| name | forms-and-validation |
| description | Use when building forms with react-hook-form + zod and shadcn/ui Form — schema validation, inline errors, accessible fields, async submit with loading and server errors, and avoiding uncontrolled-input bugs. |
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
useState per field (stop — use RHF).
The pattern
"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.infer keeps 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 an Alert.
- On success: toast, then redirect or reset (
form.reset()).
Pitfalls
useState per 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.