Forms at Scale: React Hook Form & Zod
While basic controlled inputs work well for simple contact forms, complex enterprise forms with dozens of fields, interdependent validation rules, dynamic arrays, and multi-step wizards quickly degrade performance when using standard React state. Every keystroke triggers a re-render of the entire form.
React Hook Form solves this problem by using uncontrolled form inputs and native DOM subscriptions to eliminate unnecessary re-renders. Paired with Zod for schema validation, it provides a fast, type-safe form management experience.
Setting Up React Hook Form & Zod
Install the required packages:
npm install react-hook-form zod @hookform/resolvers
Defining Type-Safe Schemas with Zod
Zod allows you to define declarative validation rules and automatically infer TypeScript types:
import { z } from 'zod';
export const registrationSchema = z.object({
username: z.string().min(3, 'Username must be at least 3 characters').max(20),
email: z.string().email('Please enter a valid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
confirmPassword: z.string(),
acceptTerms: z.literal(true, {
errorMap: () => ({ message: 'You must accept the terms and conditions' }),
}),
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
// Infer TypeScript type directly from schema:
export type RegistrationFormData = z.infer<typeof registrationSchema>;
Building the Form Component
Connect the Zod schema to React Hook Form using zodResolver:
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { registrationSchema, RegistrationFormData } from './schema';
export function RegistrationForm() {
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<RegistrationFormData>({
resolver: zodResolver(registrationSchema),
mode: 'onBlur', // Validate when user leaves input field
});
const onSubmit = async (data: RegistrationFormData) => {
console.log('Valid submission data:', data);
await submitToServer(data);
reset();
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md mx-auto">
<div>
<label className="block text-sm font-semibold">Username</label>
<input
{...register('username')}
className="w-full rounded-lg border p-2 text-sm"
/>
{errors.username && (
<p className="text-xs text-red-500 mt-1">{errors.username.message}</p>
)}
</div>
<div>
<label className="block text-sm font-semibold">Email</label>
<input
type="email"
{...register('email')}
className="w-full rounded-lg border p-2 text-sm"
/>
{errors.email && (
<p className="text-xs text-red-500 mt-1">{errors.email.message}</p>
)}
</div>
<div>
<label className="block text-sm font-semibold">Password</label>
<input
type="password"
{...register('password')}
className="w-full rounded-lg border p-2 text-sm"
/>
{errors.password && (
<p className="text-xs text-red-500 mt-1">{errors.password.message}</p>
)}
</div>
<div>
<label className="block text-sm font-semibold">Confirm Password</label>
<input
type="password"
{...register('confirmPassword')}
className="w-full rounded-lg border p-2 text-sm"
/>
{errors.confirmPassword && (
<p className="text-xs text-red-500 mt-1">{errors.confirmPassword.message}</p>
)}
</div>
<div className="flex items-center gap-2">
<input type="checkbox" {...register('acceptTerms')} id="terms" />
<label htmlFor="terms" className="text-xs">I accept the Terms & Conditions</label>
</div>
{errors.acceptTerms && (
<p className="text-xs text-red-500">{errors.acceptTerms.message}</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-lg bg-primary py-2 text-white font-bold disabled:opacity-50"
>
{isSubmitting ? 'Registering...' : 'Create Account'}
</button>
</form>
);
}
Handling Dynamic Field Arrays with useFieldArray
For forms where users can dynamically add or remove rows (e.g. invoice items, multiple phone numbers, skill tags), useFieldArray provides built-in array operations:
import { useFieldArray, useForm } from 'react-hook-form';
function InvoiceForm() {
const { register, control, handleSubmit } = useForm();
const { fields, append, remove } = useFieldArray({
control,
name: 'lineItems',
});
return (
<div>
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2 mb-2">
<input {...register(`lineItems.${index}.description`)} placeholder="Item description" />
<input {...register(`lineItems.${index}.price`)} type="number" placeholder="Price" />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ description: '', price: 0 })}>
+ Add Item
</button>
</div>
);
}
Best Practices
- Validate on Blur for Optimal UX: Set
mode: 'onBlur'so validation errors display only after the user finishes typing in a field. - Infer Types Directly from Zod: Avoid duplicating TypeScript interfaces by using
z.infer<typeof schema>. - Use
Controllerfor Custom UI Components: When integrating headless select menus or date pickers (shadcn/ui), wrap them with React Hook Form's<Controller />.
Summary
React Hook Form and Zod represent the gold standard for form handling in modern React. By combining uncontrolled inputs for zero-render overhead with Zod for strict schema validation, you build responsive, fully type-safe enterprise forms.