shadcn Form
Build forms with shadcn's Form component - react-hook-form + Zod + accessible UI components, all wired together.
Search across all documentation pages
Build forms with shadcn's Form component - react-hook-form + Zod + accessible UI components, all wired together.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card - copy-paste ready.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,
} from "@/components/ui/form";
const Schema = z.object({
username: z.string().min(2, "At least 2 characters"),
email: z.string().email("Invalid email"),
});
type FormData = z.infer<typeof Schema>;
export function MyForm() {
const form = useForm<FormData>({
resolver: zodResolver(Schema),
defaultValues: { username: "", email: "" },
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => console.log(data))} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="johndoe" {...field} />
</FormControl>
<FormDescription>Your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="john@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
);
}When to reach for this: When you use shadcn/ui and want consistent, accessible form fields with built-in error display, labels, and descriptions.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import {
Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,
} from "@/components/ui/form";
const FeedbackSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
category: z.enum(["bug", "feature", "question", "other"], {
required_error: "Select a category",
}),
message: z.string().min(10, "At least 10 characters").max(1000),
priority: z.enum(["low", "medium", "high"]).default("medium"),
subscribe: z.boolean().default(false),
});
type FeedbackData = z.infer<typeof FeedbackSchema>;
export function FeedbackForm() {
const form = useForm<FeedbackData>({
resolver: zodResolver(FeedbackSchema),
defaultValues: {
name: "",
email: "",
message: "",
priority: "medium",
subscribe: false,
},
});
async function onSubmit(data: FeedbackData) {
await new Promise((r) => setTimeout(r, 1000));
console.log("Feedback:", data);
form.reset();
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="max-w-lg space-y-6">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Your name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="you@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="bug">Bug Report</SelectItem>
<SelectItem value="feature">Feature Request</SelectItem>
<SelectItem value="question">Question</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>Message</FormLabel>
<FormControl>
<Textarea placeholder="Describe your feedback..." rows={4} {...field} />
</FormControl>
<FormDescription>Minimum 10 characters.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subscribe"
render={({ field }) => (
<FormItem className="flex items-start space-x-3 space-y-0">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Email updates</FormLabel>
<FormDescription>Get notified about the status of your feedback.</FormDescription>
</div>
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting} className="w-full">
{form.formState.isSubmitting ? "Sending..." : "Send Feedback"}
</Button>
</form>
</Form>
);
}What this demonstrates:
onValueChange / defaultValue<Form> is a context provider that passes the useForm return value to all children<FormField> is a wrapper around RHF's <Controller> - it provides field, fieldState, and formState<FormItem> establishes a context with a generated id for label-input association<FormLabel> renders a <label> with the correct htmlFor and error styling<FormControl> passes aria-invalid, aria-describedby, and the id to the input<FormMessage> reads the error from the field context and renders it with role="alert"<FormDescription> renders helper text linked via aria-describedbyRadio group:
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
<FormField
control={form.control}
name="plan"
render={({ field }) => (
<FormItem>
<FormLabel>Plan</FormLabel>
<FormControl>
<RadioGroup onValueChange={field.onChange} defaultValue={field.value} className="flex gap-4">
<FormItem className="flex items-center space-x-2 space-y-0">
<FormControl><RadioGroupItem value="free" /></FormControl>
<FormLabel className="font-normal">Free</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-2 space-y-0">
<FormControl><RadioGroupItem value="pro" /></FormControl>
<FormLabel className="font-normal">Pro</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>Date picker:
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
<FormField
control={form.control}
name="date"
render={({ field }) => (
<FormItem>
<FormLabel>Date</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button variant="outline">
{field.value ? format(field.value, "PPP") : "Pick a date"}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent>
<Calendar mode="single" selected={field.value} onSelect={field.onChange} />
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>// FormField is generic - name is type-checked against the form schema
<FormField
control={form.control}
name="typo" // TS error: "typo" is not in FeedbackData
render={({ field }) => /* ... */}
/>
// field.value is typed per field
<FormField
name="category"
render={({ field }) => {
field.value; // "bug" | "feature" | "question" | "other"
}}
/>Select needs onValueChange, not onChange - Radix Select does not use native change events. Fix: Wire field.onChange to onValueChange and field.value to defaultValue.
Checkbox returns boolean, not string - Use field.value (boolean) with checked, and field.onChange with onCheckedChange. Do not use {...field} spread directly.
FormControl must wrap exactly one input - It clones aria-* and id props onto its single child. Wrapping multiple elements breaks the association. Fix: Wrap only the input element.
Missing defaultValues - If defaultValues is incomplete, shadcn Select shows a blank trigger instead of the placeholder. Fix: Provide complete defaultValues or use defaultValue on Select.
| Alternative | Use When | Don't Use When |
|---|---|---|
| Plain RHF + register | You use native HTML inputs without a component library | You use shadcn/ui components |
| Formik + MUI | You are in a Material UI codebase | You use Tailwind / shadcn |
| Mantine form | You use the Mantine component library | You use shadcn |
| Custom form primitives | You need a fully custom design system | shadcn covers your needs |
<Form> is a context provider that passes the useForm return value to all children. Child components like <FormField>, <FormLabel>, <FormControl>, and <FormMessage> read from this context to wire up IDs, aria attributes, and error display.
<FormField> is a wrapper around RHF's <Controller>. Its render prop provides field, fieldState, and formState, just like Controller does.
id on the input for label associationaria-invalid when the field has an erroraria-describedby linking to the description and error message elements<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Pick one" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="a">Option A</SelectItem>
</SelectContent>
</Select>Use onValueChange, not onChange, because Radix Select does not use native change events.
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>Do not spread {...field} directly on Checkbox since it expects checked (boolean), not value.
It renders helper text below the input and is automatically linked via aria-describedby so screen readers announce it when the input is focused.
<FormMessage> reads the error from the field context (provided by FormField) and renders the error message with role="alert". No manual error checking is needed.
This happens when defaultValues in useForm is incomplete or missing for that field. Provide complete defaultValues for all fields, or set defaultValue directly on the Select component.
FormControl clones aria-* and id props onto its single child. Wrapping multiple elements breaks the label-input association and accessibility attributes. Always wrap exactly one input element.
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormItem className="flex items-center space-x-2">
<FormControl><RadioGroupItem value="free" /></FormControl>
<FormLabel>Free</FormLabel>
</FormItem>
</RadioGroup>Yes. FormField is generic and checks name against the form schema type. Passing a name that does not exist in the schema produces a TypeScript error.
Yes. For an enum field like category: z.enum(["bug", "feature"]), field.value is typed as "bug" | "feature", not string.
Yes. Use a Popover with a Calendar inside FormField. Wire field.value to selected and field.onChange to onSelect on the Calendar component.
Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥