React Forms: React Hook Form and Zod Validation (2026)
Forms are deceptively complex. Validation, error display, async submission, dynamic field arrays, file uploads, multi-step flows — each adds significant complexity. React Hook Form (RHF) v7 eliminates the re-render-per-keystroke problem of controlled inputs while Zod gives you a single type-safe schema that validates on both client and server. Together they are the industry-standard form stack for React in 2026.
Table of Contents
Controlled vs Uncontrolled: The Performance Case
In a controlled component, every keystroke calls setState, which triggers a re-render. For a simple 3-field form that's fine. For a complex form with 30 fields, conditional sections, and expensive child components, this becomes a performance problem.
React Hook Form registers inputs as uncontrolled using refs by default. State updates are isolated to the field being typed in, and the form-level state only updates on validation and submit. RHF's benchmark shows ~90% fewer re-renders compared to Formik on complex forms.
| Approach | Re-renders per keystroke | Bundle size | Boilerplate | TypeScript |
|---|---|---|---|---|
| Controlled (plain React) | Whole component tree | 0 | High | Manual |
| Formik | Whole form | ~15 KB | Medium | Good |
| React Hook Form v7 | Only the errored field | ~9 KB | Low | Excellent |
watch() and useWatch() opt specific fields into controlled behavior when you need real-time value access — for example, to conditionally show fields based on another field's value.React Hook Form v7 Basics
npm install react-hook-form zod @hookform/resolvers
// components/LoginForm.tsx
import { useForm, SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const loginSchema = z.object({
email: z.string().min(1, 'Email is required').email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
rememberMe: z.boolean().optional(),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export function LoginForm({ onSuccess }: { onSuccess: (data: LoginFormValues) => void }) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError,
reset,
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: { email: '', password: '', rememberMe: false },
});
const onSubmit: SubmitHandler<LoginFormValues> = async (data) => {
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) {
const err = await res.json();
// Set server errors on specific fields
setError('email', { type: 'server', message: err.message });
return;
}
reset();
onSuccess(data);
} catch {
setError('root', { message: 'Network error. Please try again.' });
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
{errors.root && <div role="alert" className="alert alert-danger">{errors.root.message}</div>}
<div className="mb-3">
<label htmlFor="email">Email address</label>
<input id="email" type="email" {...register('email')} className={`form-control ${errors.email ? 'is-invalid' : ''}`} />
{errors.email && <div className="invalid-feedback">{errors.email.message}</div>}
</div>
<div className="mb-3">
<label htmlFor="password">Password</label>
<input id="password" type="password" {...register('password')} className={`form-control ${errors.password ? 'is-invalid' : ''}`} />
{errors.password && <div className="invalid-feedback">{errors.password.message}</div>}
</div>
<div className="mb-3 form-check">
<input id="rememberMe" type="checkbox" {...register('rememberMe')} className="form-check-input" />
<label htmlFor="rememberMe" className="form-check-label">Remember me</label>
</div>
<button type="submit" disabled={isSubmitting} className="btn btn-primary">
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
</form>
);
}
Zod Schema Validation
Zod schemas are the single source of truth for both your TypeScript types (via z.infer) and your runtime validation logic. Define once, use everywhere — client-side form validation, API request validation, and environment variable parsing all use the same schema.
import { z } from 'zod';
// Basic types
const userSchema = z.object({
username: z
.string()
.min(3, 'Minimum 3 characters')
.max(20, 'Maximum 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores'),
age: z.number().int().min(18, 'Must be 18 or older').max(120),
website: z.string().url('Must be a valid URL').optional().or(z.literal('')),
role: z.enum(['admin', 'editor', 'viewer']),
tags: z.array(z.string()).min(1, 'Add at least one tag'),
});
// Refinements — cross-field validation
const passwordSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'], // attach error to this field
});
// Transform — coerce and shape data
const dateRangeSchema = z.object({
startDate: z.string().transform((s) => new Date(s)),
endDate: z.string().transform((s) => new Date(s)),
}).refine((data) => data.endDate > data.startDate, {
message: 'End date must be after start date',
path: ['endDate'],
});
// Infer TypeScript types directly — zero duplication
type User = z.infer<typeof userSchema>;
type DateRange = z.infer<typeof dateRangeSchema>;
const parsed = userSchema.safeParse(await req.json()). If parsed.success is false, return a 400 with parsed.error.flatten(). One schema, validated on both ends.Controller for UI Libraries
RHF's register works on native HTML inputs. For custom UI components (Select, DatePicker, Slider, etc.) that don't expose a native ref, use the Controller wrapper which gives the component controlled value and onChange props.
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import Select from 'react-select';
import DatePicker from 'react-datepicker';
import { z } from 'zod';
const schema = z.object({
country: z.object({ value: z.string(), label: z.string() }, { required_error: 'Country is required' }),
birthDate: z.date({ required_error: 'Birth date is required' }),
});
type FormValues = z.infer<typeof schema>;
const countryOptions = [
{ value: 'us', label: 'United States' },
{ value: 'in', label: 'India' },
{ value: 'uk', label: 'United Kingdom' },
];
export function ProfileForm() {
const { control, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(schema),
});
return (
<form onSubmit={handleSubmit(console.log)}>
<Controller
name="country"
control={control}
render={({ field }) => (
<Select
{...field}
options={countryOptions}
placeholder="Select country..."
classNamePrefix="react-select"
/>
)}
/>
{errors.country && <p className="text-danger">{errors.country.message}</p>}
<Controller
name="birthDate"
control={control}
render={({ field }) => (
<DatePicker
selected={field.value}
onChange={field.onChange}
placeholderText="Select birth date"
/>
)}
/>
{errors.birthDate && <p className="text-danger">{errors.birthDate.message}</p>}
<button type="submit">Save</button>
</form>
);
}
useFieldArray: Dynamic Fields
useFieldArray manages a dynamic list of fields — add/remove education entries, work experience rows, or invoice line items — while maintaining proper validation per row.
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const experienceSchema = z.object({
experiences: z.array(
z.object({
company: z.string().min(1, 'Company is required'),
role: z.string().min(1, 'Role is required'),
years: z.number().int().min(0).max(50),
})
).min(1, 'Add at least one experience'),
});
type FormValues = z.infer<typeof experienceSchema>;
export function ExperienceForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(experienceSchema),
defaultValues: { experiences: [{ company: '', role: '', years: 0 }] },
});
const { fields, append, remove } = useFieldArray({ control, name: 'experiences' });
return (
<form onSubmit={handleSubmit(console.log)}>
{fields.map((field, index) => (
<div key={field.id} className="card mb-3 p-3">
<div className="row g-2">
<div className="col-md-5">
<input placeholder="Company" {...register(`experiences.${index}.company`)} className="form-control" />
{errors.experiences?.[index]?.company && (
<small className="text-danger">{errors.experiences[index].company?.message}</small>
)}
</div>
<div className="col-md-4">
<input placeholder="Role" {...register(`experiences.${index}.role`)} className="form-control" />
{errors.experiences?.[index]?.role && (
<small className="text-danger">{errors.experiences[index].role?.message}</small>
)}
</div>
<div className="col-md-2">
<input type="number" placeholder="Years" {...register(`experiences.${index}.years`, { valueAsNumber: true })} className="form-control" />
</div>
<div className="col-md-1">
<button type="button" onClick={() => remove(index)} className="btn btn-danger btn-sm">×</button>
</div>
</div>
</div>
))}
{errors.experiences?.root && <p className="text-danger">{errors.experiences.root.message}</p>}
<button type="button" onClick={() => append({ company: '', role: '', years: 0 })} className="btn btn-secondary me-2">
+ Add Experience
</button>
<button type="submit" className="btn btn-primary">Save</button>
</form>
);
}
File Upload Handling
const fileSchema = z.object({
avatar: z
.instanceof(FileList)
.refine((files) => files.length > 0, 'Avatar is required')
.refine((files) => files[0]?.size <= 2 * 1024 * 1024, 'Max file size is 2MB')
.refine(
(files) => ['image/jpeg', 'image/png', 'image/webp'].includes(files[0]?.type),
'Only JPEG, PNG, and WebP are allowed'
),
});
function AvatarUpload() {
const { register, handleSubmit, watch, formState: { errors } } = useForm({
resolver: zodResolver(fileSchema),
});
const avatarFiles = watch('avatar');
const preview = avatarFiles?.[0] ? URL.createObjectURL(avatarFiles[0]) : null;
const onSubmit = async (data: { avatar: FileList }) => {
const formData = new FormData();
formData.append('avatar', data.avatar[0]);
await fetch('/api/upload', { method: 'POST', body: formData });
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{preview && <img src={preview} alt="Preview" style={{ width: 80, height: 80, borderRadius: '50%' }} />}
<input type="file" accept="image/*" {...register('avatar')} />
{errors.avatar && <p className="text-danger">{errors.avatar.message as string}</p>}
<button type="submit">Upload</button>
</form>
);
}
Multi-Step Form Example
Store a single schema and form instance at the top level, navigate between steps, and only call handleSubmit on the last step.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useState } from 'react';
const step1Schema = z.object({
firstName: z.string().min(1, 'Required'),
lastName: z.string().min(1, 'Required'),
email: z.string().email(),
});
const step2Schema = z.object({
plan: z.enum(['starter', 'pro', 'enterprise']),
billingCycle: z.enum(['monthly', 'annual']),
});
const fullSchema = step1Schema.merge(step2Schema);
type FormValues = z.infer<typeof fullSchema>;
const STEPS = [step1Schema, step2Schema];
export function MultiStepForm() {
const [step, setStep] = useState(0);
const isLastStep = step === STEPS.length - 1;
const { register, handleSubmit, trigger, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(fullSchema),
mode: 'onTouched',
});
const next = async () => {
// Validate only the current step's fields before advancing
const fields = Object.keys(STEPS[step].shape) as (keyof FormValues)[];
const valid = await trigger(fields);
if (valid) setStep((s) => s + 1);
};
const onSubmit = async (data: FormValues) => {
console.log('Final submission:', data);
// call API here
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-3">Step {step + 1} of {STEPS.length}</div>
{step === 0 && (
<div>
<input placeholder="First name" {...register('firstName')} className="form-control mb-2" />
{errors.firstName && <p className="text-danger">{errors.firstName.message}</p>}
<input placeholder="Last name" {...register('lastName')} className="form-control mb-2" />
{errors.lastName && <p className="text-danger">{errors.lastName.message}</p>}
<input placeholder="Email" type="email" {...register('email')} className="form-control mb-2" />
{errors.email && <p className="text-danger">{errors.email.message}</p>}
</div>
)}
{step === 1 && (
<div>
<select {...register('plan')} className="form-select mb-2">
<option value="starter">Starter</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
</select>
<select {...register('billingCycle')} className="form-select mb-2">
<option value="monthly">Monthly</option>
<option value="annual">Annual (save 20%)</option>
</select>
</div>
)}
<div className="d-flex gap-2 mt-3">
{step > 0 && <button type="button" onClick={() => setStep((s) => s - 1)} className="btn btn-secondary">Back</button>}
{!isLastStep && <button type="button" onClick={next} className="btn btn-primary">Next</button>}
{isLastStep && <button type="submit" className="btn btn-success">Submit</button>}
</div>
</form>
);
}
FAQ
Why does React Hook Form not re-render on every keystroke?
RHF registers inputs with a ref (register returns ref, name, onChange, onBlur). The onChange handler updates an internal ref, not React state. React state only updates when validation runs (on blur or submit by default). Use mode: 'onChange' in useForm to validate on every keystroke if you need it, understanding that this opt-in to controlled behavior will cause more re-renders.
When should I use watch() vs useWatch()?
watch() is called directly inside the component's render function and subscribes the whole component to value changes. useWatch({ control, name: 'field' }) is a hook that isolates subscriptions to a child component — preventing the parent from re-rendering. Use useWatch for performance-sensitive dependent fields in large forms.
Can I use React Hook Form with server components in Next.js?
No. RHF uses hooks and browser APIs, so it only runs in client components ('use client'). In Next.js App Router, mark your form component with 'use client' at the top. You can submit to server actions by calling handleSubmit with an async function that calls your server action.
How do I reset a form to specific values after a successful API response?
Use reset(values) where values is the shape returned by your API. This both clears dirty/touched/error state and populates the form with the new values — useful for edit forms where you load existing data.
What's the difference between z.string().optional() and z.string().nullable()?
.optional() means the key can be absent from the object entirely (TypeScript: string | undefined). .nullable() means the key must be present but its value can be null (TypeScript: string | null). For HTML inputs that can be empty, use .or(z.literal('')) or .transform(v => v || undefined) since empty inputs return an empty string, not null or undefined.