Form
shadcn Form component — react-hook-form integration with accessible labels, descriptions, and error messages.
Recipe
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.
Working Example
"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:
- Full form with multiple input types: text, textarea, date, time, number, select, switch
- Grid layout for side-by-side fields
- Switch toggle with label and description in a bordered container
- Toast notification on successful submission
- Proper
FormControlwrapping for each input type
Deep Dive
How It Works
<Form>is a React context provider that wrapsFormProviderfrom react-hook-form<FormField>wraps RHF's<Controller>— it providesfield,fieldState, andformStateto the render function<FormItem>generates a uniqueidand shares it via context to child components<FormLabel>renders a<label>withhtmlForautomatically set to the field'sid<FormControl>clonesid,aria-invalid, andaria-describedbyonto its child input<FormMessage>reads the error fromfieldStateand renders it with the correctidforaria-describedby<FormDescription>renders helper text with anidalso linked viaaria-describedby
Variations
Reusable 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>TypeScript Notes
// 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> }) { ... }Gotchas
-
Select needs
onValueChange, not{...field}— Radix Select does not use native events. Fix: WireonValueChange={field.onChange}anddefaultValue={field.value}. -
Switch needs
checked+onCheckedChange— Do not spread{...field}. Fix: Usechecked={field.value}andonCheckedChange={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: Usez.coerce.number()in the schema to handle coercion. -
FormMessage renders nothing when valid —
FormMessageonly renders when there is an error. No extra conditional needed.
Alternatives
| 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 |
FAQs
What three libraries work together in the shadcn Form component?
react-hook-formfor form state management and validationzodfor schema-based validation@hookform/resolvers/zodto bridge Zod schemas into react-hook-form
What does each shadcn Form sub-component do?
Form— wraps RHF'sFormProvider, shares form contextFormField— wraps RHF'sController, providesfieldandfieldStateFormItem— generates a uniqueidshared by label, input, and messageFormLabel— renders<label>with auto-sethtmlForFormControl— clonesid,aria-invalid,aria-describedbyonto child inputFormMessage— renders error message linked viaaria-describedby
How do you integrate a Select component with the Form?
<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>
)} />Gotcha: Why does spreading {...field} on a Select or Switch not work?
- Radix Select uses
onValueChangenotonChange, anddefaultValuenotvalue - Switch uses
checkedandonCheckedChangeinstead of standard input props - Fix: wire the specific props manually instead of spreading
{...field}
How do you handle number inputs that return strings?
<Input type="number" {...field}>always returns a string value- Fix: use
z.coerce.number()in the Zod schema to coerce strings to numbers during validation
How do you build a reusable text field wrapper?
function 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>
)}
/>
);
}How does FormField catch incorrect field names at compile time?
FormFieldis generic and typed against the Zod schema- Passing
name="nonexistent"produces a TypeScript error if the field is not in the schema field.valueis also typed per field (e.g., a union type for enums)
Gotcha: Why must FormControl wrap exactly one child element?
FormControlclones ARIA attributes (id,aria-invalid,aria-describedby) onto its single child- Multiple children breaks the cloning mechanism
- Fix: wrap only the input element, not additional elements like icons
How do you embed a form inside a Dialog?
<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>How do you use a Switch toggle with shadcn Form?
<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>
)} />What TypeScript type do you use when passing the form object to child components?
import type { UseFormReturn } from "react-hook-form";
function Subform({ form }: { form: UseFormReturn<EventData> }) { ... }Does FormMessage render anything when the field is valid?
- No.
FormMessageonly renders whenfieldStatecontains an error - No conditional wrapping is needed around it
Related
- shadcn Setup — installation and theming
- Button — submit buttons
- Dialog — forms in dialogs
- shadcn Form (forms section) — detailed Form patterns