shadcn ui : Form Composition (react-hook-form + zod)
The shadcn Form layer is a thin set of seven composition primitives wrapping react-hook-form and wiring accessibility attributes automatically. Every claim in this skill traces to the canonical source at apps/v4/registry/new-york-v4/ui/form.tsx in the shadcn-ui/ui repository and to the verbatim API of react-hook-form and zod.
ALWAYS read shadcn-core-architecture first if the copy-not-install paradigm is unclear. ALWAYS read shadcn-syntax-variant-cva if cn() and the variant model are unclear.
Quick Reference
Minimal form snippet
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
const formSchema = z.object({
username: z.string().min(2, "At least 2 characters.").max(50),
})
type FormValues = z.infer<typeof formSchema>
export function ProfileForm() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { username: "" },
})
function onSubmit(values: FormValues) {
console.log(values)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>Your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
)
}
Seven-primitive map
| Primitive | Role | a11y wiring |
|---|
Form | Re-export of FormProvider from react-hook-form. Spreads the useForm return value as context. | Provides form context; required ancestor of every FormField. |
FormField | Wraps react-hook-form's Controller. Adds a context that publishes name to the descendants. | Bridges schema field name to rendered control. |
FormItem | Grid layout container. Creates a unique React.useId() and publishes it as formItemId, formDescriptionId, formMessageId. | Ties label / control / description / message together via shared id. |
FormLabel | Wraps shadcn Label. Reads formItemId from context and sets htmlFor. Adds data-error when the field has an error. | htmlFor -> control id; data-error enables destructive colour token. |
FormControl | Renders Radix Slot.Root. Forwards props to its single child (the actual input). | Sets id, aria-describedby (description + message ids when error, description-only otherwise), aria-invalid (true when error). |
FormDescription | Helper-text <p> with the description id. | Linked via aria-describedby on the control. |
FormMessage | Error-text <p> with the message id. Renders the current zod error message when the field has one. | Linked via aria-describedby on the control only when error is present. |
ALWAYS use all seven primitives together. NEVER omit FormMessage : it is the only primitive that renders the zod error.
The seven Form primitives
ALWAYS compose primitives in this exact tree :
<Form {...form}> // FormProvider context
<form onSubmit={form.handleSubmit(onSubmit)}> // native <form> element
<FormField // Controller wrapper
control={form.control}
name="<schema-key>"
render={({ field }) => (
<FormItem> // grid layout + id context
<FormLabel>...</FormLabel> // htmlFor wired to control
<FormControl> // Slot wrapping the input
<Input {...field} /> // or Select, Checkbox, etc.
</FormControl>
<FormDescription>...</FormDescription> // optional helper text
<FormMessage /> // zod error renders here
</FormItem>
)}
/>
</form>
</Form>
Why each primitive exists
- Form :
FormProvider re-exported under a shorter name. Spreading {...form} publishes control, register, handleSubmit, formState, setValue, reset, watch, getValues, and trigger to every descendant via React context. NEVER omit the spread : FormField calls useFormContext and throws when context is missing.
- FormField : a generic React component that internally renders
react-hook-form's Controller and wraps it in a FormFieldContext.Provider that publishes name. The render prop receives { field, fieldState, formState } from Controller. ALWAYS pass control={form.control} and name="<schema-key>". The name MUST match a key in the zod schema; mismatches are runtime-silent.
- FormItem : a
<div className="grid gap-2"> that calls React.useId() once per item and publishes id, formItemId, formDescriptionId, formMessageId via FormItemContext. ALWAYS render exactly one FormItem per FormField. NEVER share an id across fields by reusing a single FormItem.
- FormLabel : wraps shadcn
Label. Internally calls useFormField() to read formItemId (sets htmlFor) and error (sets data-error="true", which the class string data-[error=true]:text-destructive colours red).
- FormControl : renders Radix
Slot.Root. It takes exactly ONE child and forwards id={formItemId}, aria-describedby={!error ? formDescriptionId : formDescriptionId + " " + formMessageId}, and aria-invalid={!!error} to that child. ALWAYS pass a single React element child. NEVER nest two siblings under : Slot throws.
ALWAYS leave the body of FormMessage empty (<FormMessage />); the component reads error?.message itself. NEVER hand-roll an error <p> outside the primitive : aria-describedby will not wire to it.
Schema-first pattern
ALWAYS define the zod schema OUTSIDE the component, derive the TypeScript type via z.infer, and pass both to useForm. NEVER inline the schema inside the component body : the schema object would be re-created on every render, zodResolver(schema) would also re-create, and react-hook-form's memoised resolver reference would invalidate every render. See references/anti-patterns.md §3 for the failure mode.
const formSchema = z.object({
email: z.string().min(1, "Required.").email("Invalid email."),
age: z.coerce.number().int().min(18, "Must be 18+."),
agree: z.boolean().refine((v) => v === true, "You must agree."),
})
type FormValues = z.infer<typeof formSchema>
export function MyForm() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { email: "", age: 18, agree: false },
})
}
The <FormValues> generic on useForm propagates through form.control, field.value, the onSubmit data argument, and form.setValue's name+value pair. ALWAYS pass the generic explicitly; TypeScript will refuse mismatched name="" strings at compile time.
Controller vs register decision
ALWAYS use FormField (which uses Controller internally) for any control that does NOT accept a native ref. Native HTML inputs (<input>, <textarea>, <select>) accept ref and can be wired with form.register("name"). Radix-wrapped shadcn controls do NOT accept native ref : they use controlled value + onValueChange (or checked + onCheckedChange). Using register on these returns nothing useful and the value never enters form-state.
| Control | Path | Why |
|---|
shadcn Input | <FormControl><Input {...field} /></FormControl> via FormField | native <input> under the hood but spreading field covers everything |
shadcn Textarea | <FormControl><Textarea {...field} /></FormControl> | native <textarea> |
shadcn Select | FormField + render with Select value={field.value} onValueChange={field.onChange} | controlled, no native ref |
shadcn Checkbox | FormField + render with Checkbox checked={field.value} onCheckedChange={field.onChange} | controlled, no native ref |
shadcn RadioGroup | FormField + render with RadioGroup value={field.value} onValueChange={field.onChange} | controlled, no native ref |
shadcn Switch | FormField + render with Switch checked={field.value} onCheckedChange={field.onChange} | controlled, no native ref |
shadcn Slider | FormField + render with Slider value={field.value} onValueChange={field.onChange} | controlled, no native ref |
Native <input> | register("name") is permitted, but loses auto-wired aria-describedby, aria-invalid, and data-error | bypasses FormControl wiring |
NEVER use register for Select, Checkbox, RadioGroup, Switch, Slider, Toggle, ToggleGroup, or any other Radix-wrapped control. NEVER use register for components shipped as controlled-only.
defaultValues vs values vs reset
| Option | Behaviour | Use when |
|---|
defaultValues | Set ONCE at mount. Internal form-state initialised to this object. Changing the prop later has NO effect. | Static initial values known at render time. |
values | CONTROLLED. Whenever the prop changes, the form re-initialises to the new object (preserves dirty fields per resetOptions). | Editing an existing record where data arrives async (after fetch). |
form.reset(newValues, options?) | Imperative reset from inside an effect or callback. | After successful submit, after user clicks "Cancel", after async data lands. |
ALWAYS provide defaultValues for every field declared in the schema. NEVER leave a field absent or undefined : react-hook-form treats undefined as uncontrolled and React will warn "A component is changing an uncontrolled input to be controlled" on the first keystroke. For booleans use false; for strings use ""; for numbers use 0 or a sensible sentinel; for arrays use [].
ALWAYS use values (not defaultValues) when the initial data arrives after first render (e.g. useQuery for editing a record). Mixing the two is supported but values wins on prop change.
const { data } = useQuery({ queryKey: ["user", id], queryFn: getUser })
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
values: data,
defaultValues: { name: "", email: "" },
})
Submit handling
form.handleSubmit(onValid, onInvalid?) returns a single event-handler function suitable for the native <form onSubmit={...}> prop. ALWAYS pass the RESULT of calling handleSubmit, never handleSubmit itself.
<form onSubmit={form.handleSubmit(onValid, onInvalid)}>...</form>
function onValid(values: FormValues) {
}
function onInvalid(errors: FieldErrors<FormValues>) {
console.warn(errors)
}
handleSubmit :
- Prevents the native form submit (calls
event.preventDefault() internally).
- Runs
zodResolver (or any other resolver) against the current values.
- On success, calls
onValid(values, event).
- On failure, calls
onInvalid(errors, event) if provided; updates formState.errors either way.
- Sets
formState.isSubmitting to true while onValid returns a Promise; back to false on settle.
ALWAYS check form.formState.isSubmitting to disable the submit button during async work. NEVER call onValid outside handleSubmit (e.g. <Button onClick={() => onValid(form.getValues())}>) : validation will not run.
Async validation
zod supports async refinement via .refine(async (v) => ...) and .superRefine. react-hook-form reflects pending status via formState.isValidating. Examples : checking whether an email is already taken, validating a coupon code against an API.
const formSchema = z.object({
email: z
.string()
.email()
.refine(
async (email) => {
const res = await fetch(`/api/check-email?email=${email}`)
const { taken } = await res.json()
return !taken
},
{ message: "Email already in use." }
),
})
ALWAYS await every async check inside the refine callback. NEVER fire-and-forget an async call from refine : the resolver expects a boolean (or boolean Promise) return; a stale fetch resolves after the user has moved on. See references/examples.md for a debounced async-validation pattern.
RSC and 'use client'
The shadcn Form component uses React hooks (useId, useContext, useFormContext, useFormState). ALWAYS mark every file that imports from @/components/ui/form with "use client" at the top, even when wrapping the form in a server component shell. Without the directive Next.js (App Router) and any other RSC framework throws "useContext is not a function" on the server.
ALWAYS render the server-component shell as the form's PARENT and keep the entire form in a single client component. NEVER split <Form> and <FormField> across a server-client boundary : the React context cannot cross it.
Forward-pointer : the Field primitive (2026)
shadcn ui 2026 introduces a separate Field family (Field, FieldLabel, FieldDescription, FieldError, FieldGroup, FieldSet, FieldLegend, FieldContent, FieldSeparator, FieldTitle) that provides the same accessibility wiring without coupling to react-hook-form. The official docs now show the canonical react-hook-form pattern composing raw Controller with Field primitives. The Form composition documented in this skill remains in the registry and is fully supported; Field is the recommended new path for new code AND for TanStack Form integrations.
ALWAYS read shadcn-syntax-field (Batch 4) when starting a new project. For existing projects that already use Form / FormField / FormItem, this skill remains the canonical reference; the two systems work side by side.
Companion Skills
Reference files
- references/methods.md : complete API signatures (Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage, useFormField, useForm options, Controller props, zodResolver, common zod schema patterns).
- references/examples.md : sign-in form, Select + Controller, Checkbox + Controller, native Input via register, async refine, defaultValues vs values, custom FormField composition.
- references/anti-patterns.md : seven canonical anti-patterns with WHY each fails and the fix.
Sources
All claims trace to URLs in SOURCES.md :
Verified 2026-05-19.