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.
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.84.0, zod@4.3.6, @hookform/resolvers@5.2.2
Quick Start (10 Minutes)
1. Install Packages
bun add react-hook-form@7.84.0 zod@4.3.6 @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
import { useForm } from'react-hook-form'import { zodResolver } from'@hookform/resolvers/zod'import { z } from'zod'// 1. Define validation schemaconst loginSchema = z.object({
email: z.email({ error: 'Invalid email address' }),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
// 2. Infer TypeScript type from schematypeLoginFormData = z.infer<typeof loginSchema>
functionLoginForm() {
// 3. Initialize form with zodResolverconst {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
},
})
// 4. Handle form submissionconstonSubmit = async (data: LoginFormData) => {
// Data is guaranteed to be valid hereconsole.log('Valid data:', data)
}
return (
<formonSubmit={handleSubmit(onSubmit)}><div><labelhtmlFor="email">Email</label><inputid="email"type="email" {...register('email')} />
{errors.email && (
<spanrole="alert"className="error">
{errors.email.message}
</span>
)}
</div><div><labelhtmlFor="password">Password</label><inputid="password"type="password" {...register('password')} />
{errors.password && (
<spanrole="alert"className="error">
{errors.password.message}
</span>
)}
</div><buttontype= =>
{isSubmitting ? 'Logging in...' : 'Login'}
)
}
CRITICAL:
Always set defaultValues to 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.tsimport { z } from'zod'// SAME schema on serverconst loginSchema = z.object({
email: z.email({ error: 'Invalid email address' }),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
exportasyncfunctionloginHandler(req: Request) {
try {
const data = loginSchema.parse(await req.json())
// Data is type-safe and validatedreturn { success: true }
} catch (error) {
if (error instanceof z.ZodError) {
return { success: false, errors: z.flattenError(error).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.tsx
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 validatedefaultValues: {}, // 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
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
❌ 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:
// ❌ BADconst form = useForm()
// ✅ GOODconst form = useForm({
defaultValues: {
email: '',
password: '',
}
})
Error #2: Zod v4 Type Inference Issues
Error: Type inference doesn't work correctly
Solution:
// Explicitly type useForm if neededconst form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
})