Build type-safe validated forms in React using React Hook Form and Zod schema validation. Single schema works on both client and server for DRY validation with full TypeScript type inference via z.infer. Use when: building forms with validation, integrating shadcn/ui Form components, implementing multi-step wizards, handling dynamic field arrays with useFieldArray, or fixing uncontrolled to controlled warnings, resolver errors, async validation issues.
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
README.mdreferences/accessibility.mdreferences/error-handling.mdreferences/links-to-official-docs.mdreferences/performance-optimization.mdreferences/rhf-api-reference.mdreferences/shadcn-integration.mdreferences/top-errors.mdreferences/zod-schemas-guide.mdrules/react-hook-form-zod.mdscripts/check-versions.shtemplates/advanced-form.tsxtemplates/async-validation.tsxtemplates/basic-form.tsxtemplates/custom-error-display.tsxtemplates/dynamic-fields.tsxtemplates/multi-step-form.tsxtemplates/package.jsontemplates/server-validation.tstemplates/shadcn-form.tsxname: react-hook-form-zod description: | Build type-safe validated forms in React using React Hook Form and Zod schema validation. Single schema works on both client and server for DRY validation with full TypeScript type inference via z.infer.
Use when: building forms with validation, integrating shadcn/ui Form components, implementing multi-step wizards, handling dynamic field arrays with useFieldArray, or fixing uncontrolled to controlled warnings, resolver errors, async validation issues.
Status: Production Ready ✅ Last Verified: 2025-11-28 Latest Versions: react-hook-form@7.66.1, zod@4.1.13, @hookform/resolvers@5.2.2
npm install react-hook-form@7.66.1 zod@4.1.13 @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())
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
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.
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 }))
}
}
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() }),
])
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>
)} />
register (uncontrolled) over Controller (controlled) for standard inputswatch('email') not watch() (isolates re-renders to specific fields)shouldUnregister: true for multi-step forms (clears data on unmount)✅ 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
Zod v4 Type Inference - #13109 (Closed 2025-11-01): Use z.infer<typeof schema>. Resolved in v7.66.x+.
Uncontrolled→Controlled Warning - Always set defaultValues for 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 defaultValues in useForm options (not useState)
Controller Field Not Updating - Always spread {...field} in render function
useFieldArray Key Warnings - Use field.id as key (not index)
Schema Refinement Error Paths - Specify path in refinement: refine(..., { path: ['fieldName'] })
Transform vs Preprocess - Use transform for output, preprocess for input
Multiple Resolver Conflicts - Use single resolver (zodResolver), combine schemas if needed
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: 2025-11-28