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
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
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<typeof loginSchema>
function LoginForm() {
// 3. Initialize form with zodResolver
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
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 (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register('email')} />
{errors.email && (
<span role="alert" className="error">
{errors.email.message}
</span>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" type="password" {...register('password')} />
{errors.password && (
<span role="alert" className="error">
{errors.password.message}
</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Login'}
</button>
</form>
)
}
CRITICAL:
- Always set
defaultValuesto prevent "uncontrolled to controlled" warnings - Use
zodResolver(schema)to connect Zod validation - Type form with
z.infer<typeof schema>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
// 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
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<FormData>({
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
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<typeof schema> 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 formsadvanced-form.tsx- Nested objects, arrays, dynamic fieldsshadcn-form.tsx- Integration with shadcn/uimulti-step-form.tsx- Wizard/stepper formsasync-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' useWatchvswatch()- 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:
// ❌ 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:
// Explicitly type useForm if needed
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
})
Source: GitHub Issue #13109
Error #3: Resolver Not Found
Error:
Module not found: Can't resolve '@hookform/resolvers/zod'
Solution:
# Install the resolvers package
bun add @hookform/resolvers@5.2.2