Form
shadcn Form component - react-hook-form integration with accessible labels, descriptions, and error messages.
Search across all documentation pages
shadcn Form component - react-hook-form integration with accessible labels, descriptions, and error messages.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card - copy-paste ready.
npx shadcn@latest add form input button label"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, FormField, FormItem, FormLabel, FormMessage,
} from "@/components/ui/form";
const Schema = z.object({
email: z.string().email(),
name: z.string().min(2),
});
export function BasicForm() {
const form = useForm<z.infer<typeof Schema>>({
resolver: zodResolver(Schema),
defaultValues: { email: "", name: "" },
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(console.log)} className="space-y-4">
<FormField control={form.control} name="name" render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl><Input type="email" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<Button type="submit">Submit</Button>
</form>
</Form>
);
}When to reach for this: When you use shadcn/ui and need forms with validation - the Form component automates ARIA attributes, error display, and label association.
"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 { Switch } from "@/components/ui/switch";
import {
Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,
} from "@/components/ui/form";
import { toast } from "sonner";
const EventSchema = z.object({
title: z.string().min(1, "Title is required").max(100),
description: z.string().max(500).optional(),
date: z.string().min(1, "Date is required"),
time: z.string().min(1, "Time is required"),
location: z.string().min(1, "Location is required"),
type: z.enum(["conference", "meetup", "workshop", "webinar"]),
maxAttendees: z.coerce.number().int().positive().max(10000),
isPublic: z.boolean().default(true),
requiresRegistration: z.boolean().default(false),
});
type EventData = z.infer<typeof EventSchema>;
export function CreateEventForm() {
const form = useForm<EventData>({
resolver: zodResolver(EventSchema),
defaultValues: {
title: "",
description: "",
date: "",
time: "",
location: "",
type: "meetup",
maxAttendees: 100,
isPublic: true,
requiresRegistration: false,
},
});
async function onSubmit(data: EventData) {
await new Promise((r) => setTimeout(r, 1000));
toast.success("Event created!", { description: data.title });
form.reset();
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="max-w-lg space-y-6">
<FormField control={form.control} name="title" render={({ field }) => (
<FormItem>
<FormLabel>Event Title</FormLabel>
<FormControl><Input placeholder="React Conf 2025" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="description" render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl><Textarea placeholder="What is this event about?" rows={3} {...field} /></FormControl>
<FormDescription>Optional. Max 500 characters.</FormDescription>
<FormMessage />
</FormItem>
)} />
<div className="grid grid-cols-2 gap-4">
<FormField control={form.control} name="date" render={({ field }) => (
<FormItem>
<FormLabel>Date</FormLabel>
<FormControl><Input type="date" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="time" render={({ field }) => (
<FormItem>
<FormLabel>Time</FormLabel>
<FormControl><Input type="time" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<FormField control={form.control} name="location" render={({ field }) => (
<FormItem>
<FormLabel>Location</FormLabel>
<FormControl><Input placeholder="City, venue, or URL" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<div className="grid grid-cols-2 gap-4">
<FormField control={form.control} name="type" render={({ field }) => (
<FormItem>
<FormLabel>Event Type</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger><SelectValue /></SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="conference">Conference</SelectItem>
<SelectItem value="meetup">Meetup</SelectItem>
<SelectItem value="workshop">Workshop</SelectItem>
<SelectItem value="webinar">Webinar</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="maxAttendees" render={({ field }) => (
<FormItem>
<FormLabel>Max Attendees</FormLabel>
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<FormField control={form.control} name="isPublic" render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3">
<div>
<FormLabel>Public Event</FormLabel>
<FormDescription>Anyone can discover this event.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)} />
<FormField control={form.control} name="requiresRegistration" render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3">
<div>
<FormLabel>Require Registration</FormLabel>
<FormDescription>Attendees must register in advance.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)} />
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Creating..." : "Create Event"}
</Button>
</form>
</Form>
);
}What this demonstrates:
FormControl wrapping for each input type<Form> is a React context provider that wraps FormProvider from react-hook-form<FormField> wraps RHF's <Controller> - it provides field, fieldState, and formState to the render function<FormItem> generates a unique id and shares it via context to child components<FormLabel> renders a <label> with htmlFor automatically set to the field's id<FormControl> clones id, aria-invalid, and aria-describedby onto its child input<FormMessage> reads the error from fieldState and renders it with the correct id for aria-describedby<FormDescription> renders helper text with an id also linked via aria-describedbyReusable field wrapper:
function TextField({
form,
name,
label,
placeholder,
description,
}: {
form: UseFormReturn<any>;
name: string;
label: string;
placeholder?: string;
description?: string;
}) {
return (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<Input placeholder={placeholder} {...field} />
</FormControl>
{description && <FormDescription>{description}</FormDescription>}
<FormMessage />
</FormItem>
)}
/>
);
}Form in a dialog:
<Dialog>
<DialogTrigger asChild><Button>Edit</Button></DialogTrigger>
<DialogContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Edit Item</DialogTitle>
</DialogHeader>
{/* FormFields here */}
<DialogFooter>
<Button type="submit">Save</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>// FormField is generic - catches wrong field names
<FormField
control={form.control}
name="nonexistent" // TS error if not in schema
render={({ field }) => <Input {...field} />}
/>
// field.value is typed per field
<FormField name="type" render={({ field }) => {
field.value; // "conference" | "meetup" | "workshop" | "webinar"
}} />
// UseFormReturn type for passing form to children
import type { UseFormReturn } from "react-hook-form";
function Subform({ form }: { form: UseFormReturn<EventData> }) { ... }Select needs onValueChange, not {...field} - Radix Select does not use native events. Fix: Wire onValueChange={field.onChange} and defaultValue={field.value}.
Switch needs checked + onCheckedChange - Do not spread {...field}. Fix: Use checked={field.value} and onCheckedChange={field.onChange}.
FormControl must wrap one element - It clones ARIA props onto its single child. Multiple children breaks it. Fix: Wrap only the input element in FormControl.
Number inputs return strings - <Input type="number" {...field}> returns a string. Fix: Use z.coerce.number() in the schema to handle coercion.
FormMessage renders nothing when valid - FormMessage only renders when there is an error. No extra conditional needed.
| Alternative | Use When | Don't Use When |
|---|---|---|
| Plain RHF + register | You use native inputs without a component library | You use shadcn and want consistent accessible forms |
| Formik + Yup | You are in a Formik-based codebase | Starting fresh with modern React |
| Native form + Server Actions | You want minimal client JS | You need instant field-level validation |
| AutoForm (shadcn extension) | You want forms auto-generated from Zod schemas | You need custom layout and behavior |
react-hook-form for form state management and validationzod for schema-based validation@hookform/resolvers/zod to bridge Zod schemas into react-hook-formForm - wraps RHF's FormProvider, shares form contextFormField - wraps RHF's Controller, provides field and fieldStateFormItem - generates a unique id shared by label, input, and messageFormLabel - renders <label> with auto-set htmlForFormControl - clones id, aria-invalid, aria-describedby onto child inputFormMessage - renders error message linked via aria-describedby<FormField name="type" render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger><SelectValue /></SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="meetup">Meetup</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} />onValueChange not onChange, and defaultValue not valuechecked and onCheckedChange instead of standard input props{...field}<Input type="number" {...field}> always returns a string valuez.coerce.number() in the Zod schema to coerce strings to numbers during validationfunction TextField({ form, name, label, placeholder }: {
form: UseFormReturn<any>;
name: string;
label: string;
placeholder?: string;
}) {
return (
<FormField control={form.control} name={name}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl><Input placeholder={placeholder} {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}FormField is generic and typed against the Zod schemaname="nonexistent" produces a TypeScript error if the field is not in the schemafield.value is also typed per field (e.g., a union type for enums)FormControl clones ARIA attributes (id, aria-invalid, aria-describedby) onto its single child<DialogContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader><DialogTitle>Edit</DialogTitle></DialogHeader>
{/* FormFields here */}
<DialogFooter><Button type="submit">Save</Button></DialogFooter>
</form>
</Form>
</DialogContent><FormField name="isPublic" render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3">
<div>
<FormLabel>Public</FormLabel>
<FormDescription>Visible to everyone.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)} />import type { UseFormReturn } from "react-hook-form";
function Subform({ form }: { form: UseFormReturn<EventData> }) { ... }FormMessage only renders when fieldState contains an errorReviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥