**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 * * * ```
npm install react-hook-form@7.70.0 zod@4.3.5 @hookform/resolvers@5.2.2const 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())
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)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 `**Zod v4.3.0+ Features**:` // Exact optional (can omit field, but NOT undefined) z.string().exactOptional() // Exclusive union (exactly one must match) z.xor([z.string(), z.number()]) // Import from JSON Schema z.fromJSONSchema({ type: "object", properties: { name: { type: "string" } } })
<input {...register('email')} /> // Uncontrolled, best performance<Controller name="category" control={control} render={({ field }) => <CustomSelect {...field} />} // MUST spread {...field} />
register.{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 })) } }
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() }), ]) `**Conditional Fields with shouldUnregister**:` const form = useForm({ resolver: zodResolver(schema), shouldUnregister: false, // Keep values when fields unmount (default) }) // Or use conditional schema validation: 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"], })
Form from "react-hook-form" instead of from shadcn. Always import:// ✅ Correct: import { useForm } from "react-hook-form"; import { Form, FormField, FormItem } from "@/components/ui/form"; // shadcn // ❌ Wrong (auto-import mistake): 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> )} />
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)formState properties can freeze for 10-15 seconds during registration. (Issue #13129)// ❌ Slow with 300+ fields: const { isDirty, isValid } = form.formState; // ✅ Fast: const handleSubmit = () => { if (!form.formState.isValid) return; // Read inline only when needed }; `2. **Use mode: "onSubmit"** - Don't validate on every change:` const form = useForm({ resolver: zodResolver(largeSchema), mode: "onSubmit", // Validate only on submit, not onChange }); `3. **Split into sub-forms** - Multiple smaller forms with separate schemas:` // Instead of one 300-field form, use 5-6 forms with 50-60 fields each const form1 = useForm({ resolver: zodResolver(schema1) }); // Fields 1-50 const form2 = useForm({ resolver: zodResolver(schema2) }); // Fields 51-100 `4. **Lazy render fields** - Use tabs/accordion to mount only visible fields:` // Only mount fields for active tab, reduces initial registration time {activeTab === 'personal' && <PersonalInfoFields />} {activeTab === 'address' && <AddressFields />}
field.id as key in useFieldArray (not index){...field} in Controller renderz.infer<typeof schema> for type inferencesetValue())defaultValues for all fieldserrors.address?.street?.messagekey={field.id} in useFieldArray (not index)setError() to map server errors to fieldsdefaultValues in useForm options (not useState){...field} in render functionfield.id as key (not index)path in refinement: refine(..., { path: ['fieldName'] })transform for output, preprocess for input.optional()) to empty string "" incorrectly triggers validation errors. Workarounds: Use .nullish(), .or(z.literal("")), or z.preprocess((val) => val === "" ? undefined : val, z.email().optional())useFieldArray only works with arrays of objects, not primitives like string[]. Workaround: Wrap primitives in objects: [{ value: "string" }] instead of ["string"]key)form.reset() after Server Actions submission causes validation errors on next submit. Fixed in v7.65.0+. Before fix: Use setValue() instead of reset()isValidating=false but errors not populated yet. Don't derive validity from errors alone. Use: !errors.field && !isValidatingZodError directly instead of capturing in formState.errors. Fixed in stable Zod v4.1.x+. Avoid beta versionsForm from "react-hook-form" instead of shadcn. Always import Form components from @/components/ui/formid → key:// V7: const { fields } = useFieldArray({ control, name: "items" }); fields.map(field => <div key={field.id}>...</div>) // V8: const { fields } = useFieldArray({ control, name: "items" }); fields.map(field => <div key={field.key}>...</div>) // keyName prop removed `2. **Watch component: `names` → `name`**:` // V7: <Watch names={["email", "password"]} /> // V8: <Watch name={["email", "password"]} /> `3. **watch() callback API removed**:` // V7: watch((data, { name, type }) => { console.log(data, name, type); }); // V8: Use useWatch or manual subscription const data = useWatch({ control }); useEffect(() => { console.log(data); }, [data]); `4. **setValue() no longer updates useFieldArray**:` // V7: setValue("items", newArray); // Updates field array // V8: Must use replace() API const { replace } = useFieldArray({ control, name: "items" }); replace(newArray);
key instead of random id)