Overview
The shadcn Form component is a thin wrapper around React Hook Form and Zod. It provides controlled, accessible form fields with a consistent error-display contract. The pattern is: define a Zod schema, create a form with useForm and zodResolver, wrap each input in a FormField, and let FormMessage surface errors automatically. This page covers that pattern and the rules that keep forms correct and accessible.
Define the Zod schema before writing the form
Write the schema first. It is the source of truth for field names, types, and validation rules. The TypeScript type inferred from it flows into useForm, which makes field names and error shapes fully typed.
import { z } from "zod";
const profileSchema = z.object({
displayName: z.string().min(2, "Name must be at least 2 characters").max(50),
email: z.string().email("Enter a valid email address"),
bio: z.string().max(300, "Bio cannot exceed 300 characters").optional(),
});
type ProfileFormValues = z.infer<typeof profileSchema>;Export the schema from a separate file (e.g., lib/schemas/profile.ts) if the same schema validates a server action. Running the same schema on both ends eliminates drift. See react-forms and nextjs-server-actions for the server-side counterpart.
Initialize the form with useForm and zodResolver
Pass zodResolver as the resolver and set defaultValues for every field in the schema. Missing default values produce uncontrolled-to-controlled warnings.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
function ProfileForm() {
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
defaultValues: {
displayName: "",
email: "",
bio: "",
},
});
function onSubmit(values: ProfileFormValues) {
// values is typed and validated
console.log(values);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* fields go here */}
</form>
</Form>
);
}<Form {...form}> spreads the RHF form context so every FormField inside it can call useFormContext() without prop drilling. Omitting it breaks all nested fields. See shadcn-composition for why asChild is not needed on the <form> element here.
Wrap every input in FormField with a render prop
FormField connects an input to the RHF context by name. It passes a field object to the render prop; spread it onto the input.
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem>
<FormLabel>Display name</FormLabel>
<FormControl>
<Input placeholder="Aria Nakamura" {...field} />
</FormControl>
<FormDescription>This is your public profile name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>The sub-components each have a job: FormItem provides spacing and a label association context; FormLabel renders an accessible <label> wired to the input’s id; FormControl injects the id, aria-describedby, and aria-invalid attributes onto the child; FormDescription provides hint text; FormMessage renders the current error string. Do not skip FormControl. Without it, aria-invalid is never set and screen readers cannot announce the error.
Use FormMessage for all error display; do not write your own
FormMessage reads from fieldState.error and renders the message only when an error exists. It uses role="alert" internally so screen readers announce the error on validation.
<FormMessage />
// Renders: <p role="alert" class="text-sm font-medium text-destructive">Name must be at least 2 characters</p>
// Renders nothing when there is no error.Do not write a conditional {errors.displayName && <p>} beside a shadcn field. The two patterns interfere: FormMessage manages its own presence; a second error paragraph doubles the announcement to screen readers and creates a visual conflict. See forms for the base accessibility rule.
Compose complex inputs by wrapping them in FormControl
Custom inputs, date pickers, comboboxes, and rich-text editors need the same id/aria-describedby injection that FormControl provides. Wrap any custom input in FormControl and spread the field object onto it.
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>For a Select, onValueChange maps to field.onChange and defaultValue maps to field.value. RHF tracks the value; shadcn renders it. The same pattern applies to Checkbox, RadioGroup, and Switch: map their change event to field.onChange and their value/checked state to field.value.
Handle async server errors by setting them with form.setError
When a server action returns a field-level error (duplicate email, invalid token), call form.setError to inject it into the RHF state. The error then flows through FormMessage like a validation error.
async function onSubmit(values: ProfileFormValues) {
const result = await updateProfile(values);
if (result.error?.field === "email") {
form.setError("email", {
type: "server",
message: result.error.message,
});
return;
}
}Use "root" as the field name for errors that are not tied to a specific field (e.g., network failure). Render root errors with <FormMessage /> outside the field hierarchy or read them from form.formState.errors.root. See nextjs-server-actions for the action side of this pattern.