--- name: react-hook-form-zod description: "Type-safe React forms with React Hook Form and Zod validation. Use for form schemas, field arrays, multi-step forms, or encountering validation errors, resolver issues, nested field problems." metadata: keywords: - react-hook-form - useForm - zod validation - zodResolver - "@hookform/resolvers" - form schema - register - handleSubmit - formState - useFieldArray - useWatch - useController - Controller - shadcn form - Field component - client server validation - nested validation - array field validation - dynamic fields - multi-step form - async validation - zod refine - z.infer - form error handling - uncontrolled to controlled - resolver not found - schema validation error license: MIT --- # React Hook Form + Zod Validation **Status**: Production Ready ✅ **Last Updated**: 2025-11-21 **Dependencies**: None (standalone) **Latest Versions**: react-hook-form@7.66.1, zod@4.1.12, @hookform/resolvers@5.2.2 --- ## Quick Start (10 Minutes) ### 1. Install Packages ```bash bun add react-hook-form@7.66.1 zod@4.1.12 @hookform/resolvers@5.2.2 ``` **Why These Packages**: - **react-hook-form**: Performant, flexible forms with minimal re-renders - **zod**: TypeScript-first schema validation with type inference - **@hookform/resolvers**: Adapter connecting Zod to React Hook Form ### 2. Create Your First Form ```typescript import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' // 1. Define validation schema const loginSchema = z.object({ email: z.string().email('Invalid email address'), password: z.string().min(8, 'Password must be at least 8 characters'), }) // 2. Infer TypeScript type from schema type LoginFormData = z.infer function LoginForm() { // 3. Initialize form with zodResolver const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(loginSchema), defaultValues: { email: '', password: '', }, }) // 4. Handle form submission const onSubmit = async (data: LoginFormData) => { // Data is guaranteed to be valid here console.log('Valid data:', data) } return (
{errors.email && ( {errors.email.message} )}
{errors.password && ( {errors.password.message} )}
) } ``` **CRITICAL**: - Always set `defaultValues` to prevent "uncontrolled to controlled" warnings - Use `zodResolver(schema)` to connect Zod validation - Type form with `z.infer` for full type safety - Validate on both client AND server (never trust client validation alone) **Template**: See `templates/basic-form.tsx` for complete working example ### 3. Add Server-Side Validation ```typescript // server/api/login.ts import { z } from 'zod' // SAME schema on server const loginSchema = z.object({ email: z.string().email('Invalid email address'), password: z.string().min(8, 'Password must be at least 8 characters'), }) export async function loginHandler(req: Request) { try { const data = loginSchema.parse(await req.json()) // Data is type-safe and validated return { success: true } } catch (error) { if (error instanceof z.ZodError) { return { success: false, errors: error.flatten().fieldErrors } } throw error } } ``` **Why Server Validation**: - Client validation can be bypassed (inspect element, Postman, curl) - Server validation is your security layer - Same Zod schema = single source of truth **Template**: See `templates/server-validation.ts` --- ## Core Concepts ### useForm Hook ```typescript const { register, // Register input fields handleSubmit, // Wrap onSubmit handler formState, // Form state (errors, isValid, isDirty, etc.) setValue, // Set field value programmatically getValues, // Get current form values watch, // Watch field values reset, // Reset form to defaults trigger, // Trigger validation manually control, // For Controller/useController } = useForm({ resolver: zodResolver(schema), mode: 'onSubmit', // When to validate defaultValues: {}, // Initial values (REQUIRED) }) ``` **Validation Modes**: - `onSubmit` - Validate on submit (best performance) - `onChange` - Validate on every change (live feedback) - `onBlur` - Validate when field loses focus (good balance) - `all` - Validate on submit, blur, and change **Reference**: See `references/rhf-api-reference.md` for complete API ### Zod Schema Basics ```typescript import { z } from 'zod' // Basic types const schema = z.object({ email: z.string().email('Invalid email'), age: z.number().min(18, 'Must be 18+'), terms: z.boolean().refine(val => val === true, 'Must accept terms'), }) // Nested objects const addressSchema = z.object({ user: z.object({ name: z.string(), email: z.string().email(), }), address: z.object({ street: z.string(), city: z.string(), zip: z.string().regex(/^\d{5}$/), }), }) // Arrays const tagsSchema = z.object({ tags: z.array(z.string()).min(1, 'At least one tag required'), }) // Optional and nullable const optionalSchema = z.object({ middleName: z.string().optional(), nickname: z.string().nullable(), bio: z.string().nullish(), // optional AND nullable }) ``` **Reference**: See `references/zod-schemas-guide.md` for complete patterns --- ## Critical Rules ### Always Do ✅ **Always set `defaultValues`** - Prevents "uncontrolled to controlled" warnings ✅ **Use `zodResolver` for validation** - Connects Zod schemas to React Hook Form ✅ **Infer types from schema** - Use `z.infer` for type safety ✅ **Validate on server too** - Client validation can be bypassed ✅ **Use `.register()` for native inputs** - Simple and performant ✅ **Use `Controller` for custom components** - For component libraries (MUI, Chakra, etc.) ✅ **Handle errors accessibly** - Use `role="alert"` for screen readers ✅ **Reset form after submission** - Use `reset()` to clear form state **Form Patterns**: See `templates/` for: - `basic-form.tsx` - Simple login/register forms - `advanced-form.tsx` - Nested objects, arrays, dynamic fields - `shadcn-form.tsx` - Integration with shadcn/ui - `multi-step-form.tsx` - Wizard/stepper forms - `async-validation.tsx` - Async field validation ### Never Do ❌ **Never skip `defaultValues`** - Causes "uncontrolled to controlled" errors ❌ **Never use only client validation** - Security vulnerability ❌ **Never mutate form values directly** - Use `setValue()` instead ❌ **Never ignore accessibility** - Always use proper labels and ARIA ❌ **Never forget to disable submit when `isSubmitting`** - Prevents double submissions **Performance**: See `references/performance-optimization.md` for: - When to use `mode: 'onBlur'` vs `'onChange'` - `useWatch` vs `watch()` - Re-render optimization strategies **Accessibility**: See `references/accessibility.md` for: - Proper label association - Error announcement - Focus management - Keyboard navigation --- ## Top 5 Critical Errors ### Error #1: Uncontrolled to Controlled Warning ⚠️ **Error:** ``` Warning: A component is changing an uncontrolled input to be controlled ``` **Cause**: Not setting `defaultValues` **Solution:** ```typescript // ❌ BAD const form = useForm() // ✅ GOOD const form = useForm({ defaultValues: { email: '', password: '', } }) ``` --- ### Error #2: Zod v4 Type Inference Issues **Error:** Type inference doesn't work correctly **Solution:** ```typescript // Explicitly type useForm if needed const form = useForm>({ resolver: zodResolver(schema), }) ``` **Source**: [GitHub Issue #13109](https://github.com/react-hook-form/react-hook-form/issues/13109) --- ### Error #3: Resolver Not Found **Error:** ``` Module not found: Can't resolve '@hookform/resolvers/zod' ``` **Solution:** ```bash # Install the resolvers package bun add @hookform/resolvers@5.2.2 ``` --- ### Error #4: Array Field Issues **Error:** Dynamic array fields not working with `useFieldArray` **Solution:** ```typescript const { fields, append, remove } = useFieldArray({ control, name: "items" // Must match schema field name exactly }) ``` **Template**: See `templates/dynamic-fields.tsx` --- ### Error #5: Custom Component Validation Fails **Error:** Third-party component (MUI, Chakra) doesn't validate **Solution:** Use `Controller` instead of `register`: ```typescript ( )} /> ``` **Reference**: See `references/error-handling.md` for all patterns --- **All 12 Errors**: See `references/top-errors.md` for complete documentation --- ## Common Patterns ### Basic Form ```typescript import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' const schema = z.object({ name: z.string().min(1, 'Name required'), email: z.string().email('Invalid email'), }) type FormData = z.infer function MyForm() { const { register, handleSubmit, formState: { errors } } = useForm({ resolver: zodResolver(schema), defaultValues: { name: '', email: '' } }) const onSubmit = (data: FormData) => console.log(data) return (
{errors.name && {errors.name.message}}
) } ``` **Template**: See `templates/basic-form.tsx` --- ### Dynamic Fields (useFieldArray) ```typescript import { useForm, useFieldArray } from 'react-hook-form' const schema = z.object({ items: z.array( z.object({ name: z.string(), quantity: z.number().min(1) }) ).min(1, 'At least one item required') }) function DynamicForm() { const { control, handleSubmit } = useForm({ resolver: zodResolver(schema), defaultValues: { items: [{ name: '', quantity: 1 }] } }) const { fields, append, remove } = useFieldArray({ control, name: 'items' }) return (
{fields.map((field, index) => (
))}
) } ``` **Template**: See `templates/dynamic-fields.tsx` --- ### Async Validation ```typescript const schema = z.object({ username: z.string() .min(3) .refine(async (username) => { const response = await fetch(`/api/check-username?username=${username}`) const { available } = await response.json() return available }, 'Username already taken') }) ``` **Template**: See `templates/async-validation.tsx` --- ### Multi-Step Form ```typescript function MultiStepForm() { const [step, setStep] = useState(1) const form = useForm({ resolver: zodResolver(schema), mode: 'onBlur' // Validate each step before proceeding }) const onSubmit = async (data) => { if (step < 3) { setStep(step + 1) } else { // Final submission await submitForm(data) } } return (
{step === 1 && } {step === 2 && } {step === 3 && } ) } ``` **Template**: See `templates/multi-step-form.tsx` --- ## shadcn/ui Integration ```typescript import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' function ShadcnForm() { const form = useForm({ resolver: zodResolver(schema), defaultValues: { email: '' } }) return (
( Email )} /> ) } ``` **Reference**: See `references/shadcn-integration.md` for complete patterns **Template**: See `templates/shadcn-form.tsx` --- ## Using Bundled Resources ### Templates (templates/) Copy-paste ready examples: - **basic-form.tsx** - Simple login/register forms with validation - **advanced-form.tsx** - Nested objects, arrays, conditional fields - **shadcn-form.tsx** - shadcn/ui Form component integration - **multi-step-form.tsx** - Wizard/stepper forms with step validation - **dynamic-fields.tsx** - useFieldArray for dynamic form fields - **async-validation.tsx** - Async field validation (username check, etc.) - **server-validation.ts** - Server-side validation with Zod - **custom-error-display.tsx** - Custom error message components - **package.json** - Package versions and scripts ### References (references/) Detailed documentation: - **top-errors.md** - All 12 common errors with solutions and sources - **rhf-api-reference.md** - Complete React Hook Form API reference - **zod-schemas-guide.md** - Comprehensive Zod schema patterns - **shadcn-integration.md** - shadcn/ui Form integration guide - **error-handling.md** - Error display patterns and accessibility - **performance-optimization.md** - Re-render optimization strategies - **accessibility.md** - WCAG compliance and screen reader support - **links-to-official-docs.md** - Organized official documentation links --- ## When to Load References | Reference | Load When... | |-----------|--------------| | `top-errors.md` | Debugging validation issues, type errors, or "uncontrolled to controlled" warnings | | `rhf-api-reference.md` | Need complete API for useForm, register, Controller, formState | | `zod-schemas-guide.md` | Building complex schemas (nested, arrays, conditional, async validation) | | `shadcn-integration.md` | Using shadcn/ui Form, FormField, FormItem components | | `error-handling.md` | Custom error display, validation timing, error message patterns | | `performance-optimization.md` | Form re-renders too much, optimizing watch/useWatch | | `accessibility.md` | WCAG compliance, screen readers, keyboard navigation | | `links-to-official-docs.md` | Need official documentation links | --- ## Performance Tips **Quick Tips**: - Use `mode: 'onBlur'` for balance between UX and performance - Use `useWatch` instead of `watch()` for specific fields - Memoize validation schemas outside component - Use `shouldUnregister: false` for conditional fields - Avoid `watch()` without arguments (watches all fields) **Reference**: See `references/performance-optimization.md` for complete strategies --- ## Accessibility **Quick Checklist**: - ✅ Use `