| name | react-hook-form-zod |
| description | Build type-safe validated forms using React Hook Form v7 and Zod v4. Single schema works on client and server with full TypeScript inference via z.infer.
Use when building forms, multi-step wizards, or fixing uncontrolled warnings, resolver errors, useFieldArray issues, performance problems with large forms.
|
| user-invocable | true |
React Hook Form + Zod Validation
Status: Production Ready ✅
Last Verified: 2026-01-20
Latest Versions: react-hook-form@7.71.1, 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: '' },
})
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span role="alert">{errors.email.message}</span>}
</form>
Server Validation (CRITICAL - never skip):
const data = schema.parse(await req.json())
Key Patterns
useForm Options (validation modes):
mode: 'onSubmit' (default) - Best performance
mode: 'onBlur' - Good balance
mode: 'onChange' - Live feedback, more re-renders
shouldUnregister: 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'],
})
Zod Transforms:
z.string().transform((val) => val.toLowerCase())
z.string().transform(parseInt).refine((v) => v > 0)
Zod v4.3.0+ Features:
z.string().exactOptional()
z.xor([z.string(), z.number()])
z.fromJSONSchema({ type: "object", properties: { name: { type: "string" } } })
zodResolver connects Zod to React Hook Form, preserving type safety
Registration
register (for standard HTML inputs):
<input {...register('email')} />
Controller (for third-party components):
<Controller
name="category"
control={control}
render={({ field }) => <CustomSelect {...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}
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'])
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() }),
])
Conditional Fields with shouldUnregister:
const form = useForm({
resolver: zodResolver(schema),
shouldUnregister: false,
})
z.object({
showAddress: z.boolean(),
address: z.string(),
}).refine((data) => {
if (data.showAddress) {
return data.address.length > 0;
}
return true;
}, {
message: "Address is required",
path: ["address"],
})
shadcn/ui Integration
Note: shadcn/ui deprecated the Form component. Use the Field component for new implementations (check latest docs).
Common Import Mistake: IDEs/AI may auto-import Form from "react-hook-form" instead of from shadcn. Always import:
import { useForm } from "react-hook-form";
import { Form, FormField, FormItem } from "@/components/ui/form";
import { useForm, Form } from "react-hook-form";
Legacy Form component:
<FormField control={form.control} name="username" render={({ field }) => (
<FormItem>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
Performance
- Use
register (uncontrolled) over Controller (controlled) for standard inputs
- Use
watch('email') not watch() (isolates re-renders to specific fields)
shouldUnregister: true for multi-step forms (clears data on unmount)
Large Forms (300+ Fields)
Warning: Forms with 300+ fields using a resolver (Zod/Yup) AND reading formState properties can freeze for 10-15 seconds during registration. (Issue #13129)
Performance Characteristics:
- Clean (no resolver, no formState read): Almost immediate
- With resolver only: Almost immediate
- With formState read only: Almost immediate
- With BOTH resolver + formState read: ~9.5 seconds for 300 fields
Workarounds:
- Avoid destructuring formState - Read properties inline only when needed:
const { isDirty, isValid } = form.formState;
const handleSubmit = () => {
if (!form.formState.isValid) return;
};
- Use mode: "onSubmit" - Don't validate on every change:
const form = useForm({
resolver: zodResolver(largeSchema),
mode: "onSubmit",
});
- Split into sub-forms - Multiple smaller forms with separate schemas:
const form1 = useForm({ resolver: zodResolver(schema1) });
const form2 = useForm({ resolver: zodResolver(schema2) });
- Lazy render fields - Use tabs/accordion to mount only visible fields:
{activeTab === 'personal' && <PersonalInfoFields />}
{activeTab === 'address' && <AddressFields />}
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 (20 Prevented)
-
Zod v4 Type Inference - #13109: Use z.infer<typeof schema> explicitly. Resolved in v7.66.x+. Note: @hookform/resolvers has TypeScript compatibility issues with Zod v4 (#813). Workaround: Use import { z } from 'zod/v3' or wait for resolver update.
-
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
-
Zod v4 Optional Fields Bug - #13102: Setting optional fields (.optional()) to empty string "" incorrectly triggers validation errors. Workarounds: Use .nullish(), .or(z.literal("")), or z.preprocess((val) => val === "" ? undefined : val, z.email().optional())
-
- : Design limitation. only works with arrays of objects, not primitives like . Workaround: Wrap primitives in objects: instead of
Upcoming Changes in V8 (Beta)
React Hook Form v8 (currently in beta as of v8.0.0-beta.1, released 2026-01-11) introduces breaking changes. RFC Discussion #7433
Breaking Changes:
- useFieldArray:
id → key:
const { fields } = useFieldArray({ control, name: "items" });
fields.map(field => <div key={field.id}>...</div>)
const { fields } = useFieldArray({ control, name: "items" });
fields.map(field => <div key={field.key}>...</div>)
- Watch component:
names → name:
<Watch names={["email", "password"]} />
<Watch name={["email", "password"]} />
- watch() callback API removed:
watch((data, { name, type }) => {
console.log(data, name, type);
});
const data = useWatch({ control });
useEffect(() => {
console.log(data);
}, [data]);
- setValue() no longer updates useFieldArray:
setValue("items", newArray);
const { replace } = useFieldArray({ control, name: "items" });
replace(newArray);
V8 Benefits:
- Fixes SSR hydration mismatch (deterministic
key instead of random id)
- Improved performance
- Better TypeScript inference
Migration Timeline: V8 is in beta. Stable release date TBD. Monitor releases for stable version.
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-20 | Skill Version: 2.1.0 | Changes: Added 8 new known issues (Zod v4 optional fields bug, useFieldArray primitives limitation, SSR hydration mismatch, performance guidance for large forms, Next.js 16 reset() bug, validation race condition, ZodError thrown in beta, shadcn import confusion), added Zod v4.3.0 features (.exactOptional(), .xor(), z.fromJSONSchema()), added conditional field patterns with shouldUnregister, added V8 beta breaking changes section, expanded Zod v4 resolver compatibility notes, updated to react-hook-form@7.71.1