React Hook Form with Zod and Shadcn/ui
Build accessible, validated forms in React using React Hook Form with Zod schema validation.
Core Pattern
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
const formSchema = z.object({
title: z.string().min(5, "Title must be at least 5 characters."),
description: z.string().min(20, "Description must be at least 20 characters."),
})
export function MyForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
description: "",
},
})
function onSubmit(data: z.infer<typeof formSchema>) {
console.log(data)
}
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<Controller
name="title"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Title</FieldLabel>
<Input
{...field}
id={field.name}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Button type="submit">Submit</Button>
</form>
)
}
Validation Modes
React Hook Form supports different validation modes:
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange", // or "onBlur", "onSubmit", "onTouched", "all"
})
| Mode | Description |
| ------------- | -------------------------------------------------------- |
| "onChange" | Validation triggers on every change. |
| "onBlur" | Validation triggers on blur. |
| "onSubmit" | Validation triggers on submit (default). |
| "onTouched" | Validation triggers on first blur, then on every change. |
| "all" | Validation triggers on blur and change. |
Field Types
Input
Spread the field object onto the Input component:
<Controller
name="username"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Username</FieldLabel>
<Input
{...field}
id={field.name}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
Textarea
<Controller
name="description"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Description</FieldLabel>
<Textarea
{...field}
id={field.name}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
Select
Use field.value and field.onChange for Select components:
<Controller
name="language"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Language</FieldLabel>
<Select
name={field.name}
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger id={field.name} aria-invalid={fieldState.invalid}>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="es">Spanish</SelectItem>
</SelectContent>
</Select>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
Checkbox (Single)
<Controller
name="terms"
control={form.control}
render={({ field, fieldState }) => (
<Field orientation="horizontal" data-invalid={fieldState.invalid}>
<Checkbox
id={field.name}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
aria-invalid={fieldState.invalid}
/>
<FieldLabel htmlFor={field.name}>Accept terms</FieldLabel>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
Checkbox (Array)
Use array manipulation for checkbox groups:
<Controller
name="tasks"
control={form.control}
render={({ field, fieldState }) => (
<FieldSet>
<FieldLegend>Tasks</FieldLegend>
<FieldGroup data-slot="checkbox-group">
{tasks.map((task) => (
<Field key={task.id} orientation="horizontal" data-invalid={fieldState.invalid}>
<Checkbox
id={`task-${task.id}`}
name={field.name}
aria-invalid={fieldState.invalid}
checked={field.value.includes(task.id)}
onCheckedChange={(checked) => {
const newValue = checked
? [...field.value, task.id]
: field.value.filter((v) => v !== task.id)
field.onChange(newValue)
}}
/>
<FieldLabel htmlFor={`task-${task.id}`}>{task.label}</FieldLabel>
</Field>
))}
</FieldGroup>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</FieldSet>
)}
/>
Radio Group
<Controller
name="plan"
control={form.control}
render={({ field, fieldState }) => (
<FieldSet>
<FieldLegend>Plan</FieldLegend>
<RadioGroup
name={field.name}
value={field.value}
onValueChange={field.onChange}
>
{plans.map((plan) => (
<Field key={plan.id} orientation="horizontal" data-invalid={fieldState.invalid}>
<RadioGroupItem
value={plan.id}
id={`plan-${plan.id}`}
aria-invalid={fieldState.invalid}
/>
<FieldLabel htmlFor={`plan-${plan.id}`}>{plan.title}</FieldLabel>
</Field>
))}
</RadioGroup>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</FieldSet>
)}
/>
Switch
<Controller
name="twoFactor"
control={form.control}
render={({ field, fieldState }) => (
<Field orientation="horizontal" data-invalid={fieldState.invalid}>
<FieldContent>
<FieldLabel htmlFor={field.name}>Two-factor authentication</FieldLabel>
<FieldDescription>Enable for extra security.</FieldDescription>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</FieldContent>
<Switch
id={field.name}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
aria-invalid={fieldState.invalid}
/>
</Field>
)}
/>
Array Fields with useFieldArray
Manage dynamic lists with useFieldArray:
import { Controller, useFieldArray, useForm } from "react-hook-form"
const formSchema = z.object({
emails: z
.array(z.object({ address: z.string().email("Enter a valid email.") }))
.min(1, "Add at least one email.")
.max(5, "Maximum 5 emails."),
})
export function EmailForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { emails: [{ address: "" }] },
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "emails",
})
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<FieldSet>
<FieldLegend>Email Addresses</FieldLegend>
<FieldGroup>
{fields.map((field, index) => (
<Controller
key={field.id} // Important: use field.id as key
name={`emails.${index}.address`}
control={form.control}
render={({ field: controllerField, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
{...controllerField}
id={`email-${index}`}
aria-invalid={fieldState.invalid}
type="email"
/>
<Button
type="button"
variant="ghost"
onClick={() => remove(index)}
disabled={fields.length <= 1}
>
Remove
</Button>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
))}
</FieldGroup>
<Button
type="button"
variant="outline"
onClick={() => append({ address: "" })}
disabled={fields.length >= 5}
>
Add Email
</Button>
</FieldSet>
</form>
)
}
useFieldArray Methods
append(item)- Add item to end of arrayprepend(item)- Add item to start of arrayinsert(index, item)- Insert item at indexremove(index)- Remove item at indexswap(indexA, indexB)- Swap two itemsmove(from, to)- Move item from one index to anotherupdate(index, item)- Update item at indexreplace(items)- Replace entire array
Form Actions
// Reset form to default values
<Button type="button" variant="outline" onClick={() => form.reset()}>
Reset
</Button>
// Submit form
<Button type="submit">Submit</Button>
// Check form state
form.formState.isSubmitting // true during submission
form.formState.isValid // true if form is valid
form.formState.isDirty // true if form has been modified
Accessibility Checklist
- Add
idandhtmlForto link labels to inputs - Add
aria-invalid={fieldState.invalid}to form controls - Add
data-invalid={fieldState.invalid}to Field wrapper for styling - Use
FieldDescriptionfor help text - Use
FieldErrorto display validation errors - Spread
{...field}onto inputs to include name, value, onChange, onBlur