React Hook Form + Zod Validation
Status: Production Ready ✅ Last Verified: 2026-01-09 Latest Versions: react-hook-form@7.70.0, zod@4.3.5, @hookform/resolvers@5.2.2
Quick Start
npm install react-hook-form@7.70.0 zod@4.3.5 @hookform/resolvers@5.2.2
Basic Form Pattern:
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
type FormData = z.infer<typeof schema>
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '' }, // REQUIRED to prevent uncontrolled warnings
})
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span role="alert">{errors.email.message}</span>}
</form>
Server Validation (CRITICAL - never skip):
// SAME schema on server
const data = schema.parse(await req.json())
Key Patterns
useForm Options (validation modes):
mode: 'onSubmit'(default) - Best performancemode: 'onBlur'- Good balancemode: 'onChange'- Live feedback, more re-rendersshouldUnregister: true- Remove field data when unmounted (use for multi-step forms)
Zod Refinements (cross-field validation):
z.object({ password: z.string(), confirm: z.string() })
.refine((data) => data.password === data.confirm, {
message: "Passwords don't match",
path: ['confirm'], // CRITICAL: Error appears on this field
})
Zod Transforms:
z.string().transform((val) => val.toLowerCase()) // Data manipulation
z.string().transform(parseInt).refine((v) => v > 0) // Chain with refine
zodResolver connects Zod to React Hook Form, preserving type safety
Registration
register (for standard HTML inputs):
<input {...register('email')} /> // Uncontrolled, best performance
Controller (for third-party components):
<Controller
name="category"
control={control}
render={({ field }) => <CustomSelect {...field} />} // MUST spread {...field}
/>
When to use Controller: React Select, date pickers, custom components without ref. Otherwise use register.
Error Handling
Display errors:
{errors.email && <span role="alert">{errors.email.message}</span>}
{errors.address?.street?.message} // Nested errors (use optional chaining)
Server errors:
const onSubmit = async (data) => {
const res = await fetch('/api/submit', { method: 'POST', body: JSON.stringify(data) })
if (!res.ok) {
const { errors: serverErrors } = await res.json()
Object.entries(serverErrors).forEach(([field, msg]) => setError(field, { message: msg }))
}
}
Advanced Patterns
useFieldArray (dynamic lists):
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' })
{fields.map((field, index) => (
<div key={field.id}> {/* CRITICAL: Use field.id, NOT index */}
<input {...register(`contacts.${index}.name` as const)} />
{errors.contacts?.[index]?.name && <span>{errors.contacts[index].name.message}</span>}
<button onClick={() => remove(index)}>Remove</button>
</div>
))}
<button onClick={() => append({ name: '', email: '' })}>Add</button>
Async Validation (debounce):
const debouncedValidation = useDebouncedCallback(() => trigger('username'), 500)
Multi-Step Forms:
const step1 = z.object({ name: z.string(), email: z.string().email() })
const step2 = z.object({ address: z.string() })
const fullSchema = step1.merge(step2)
const nextStep = async () => {
const isValid = await trigger(['name', 'email']) // Validate specific fields
if (isValid) setStep(2)
}
Conditional Validation:
z.discriminatedUnion('accountType', [
z.object({ accountType: z.literal('personal'), name: z.string() }),
z.object({ accountType: z.literal('business'), companyName: z.string() }),
])
shadcn/ui Integration
Note: shadcn/ui deprecated the Form component. Use the Field component for new implementations (check latest docs).
Legacy Form component:
<FormField control={form.control} name="username" render={({ field }) => (
<FormItem>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
Performance
- Use
register(uncontrolled) overController(controlled) for standard inputs - Use
watch('email')notwatch()(isolates re-renders to specific fields) shouldUnregister: truefor multi-step forms (clears data on unmount)
Critical Rules
✅ Always set defaultValues (prevents uncontrolled→controlled warnings)
✅ Validate on BOTH client and server (client can be bypassed - security!)
✅ Use field.id as key in useFieldArray (not index)
✅ Spread {...field} in Controller render
✅ Use z.infer<typeof schema> for type inference
❌ Never skip server validation (security vulnerability)
❌ Never mutate values directly (use setValue())
❌ Never mix controlled + uncontrolled patterns
❌ Never use index as key in useFieldArray
Known Issues (12 Prevented)
-
Zod v4 Type Inference - #13109 (Closed 2025-11-01): Use
z.infer<typeof schema>. Resolved in v7.66.x+. -
Uncontrolled→Controlled Warning - Always set
defaultValuesfor all fields -
Nested Object Errors - Use optional chaining:
errors.address?.street?.message -
Array Field Re-renders - Use
key={field.id}in useFieldArray (not index) -
Async Validation Race Conditions - Debounce validation, cancel pending requests
-
Server Error Mapping - Use
setError()to map server errors to fields -
Default Values Not Applied - Set
defaultValuesin useForm options (not useState) -
Controller Field Not Updating - Always spread
{...field}in render function -
useFieldArray Key Warnings - Use
field.idas key (not index) -
Schema Refinement Error Paths - Specify
pathin refinement:refine(..., { path: ['fieldName'] }) -
Transform vs Preprocess - Use
transformfor output,preprocessfor input -
Multiple Resolver Conflicts - Use single resolver (zodResolver), combine schemas if needed
Bundled Resources
Templates: basic-form.tsx, advanced-form.tsx, shadcn-form.tsx, server-validation.ts, async-validation.tsx, dynamic-fields.tsx, multi-step-form.tsx, package.json
References: zod-schemas-guide.md, rhf-api-reference.md, error-handling.md, performance-optimization.md, shadcn-integration.md, top-errors.md
Docs: https://react-hook-form.com/ | https://zod.dev/ | https://ui.shadcn.com/docs/components/form
License: MIT | Last Verified: 2026-01-09