React Hook Form + Zod: Type-Safe Forms Made Simple



Building forms is one of the most common tasks in web development, yet it's surprisingly easy to get wrong. Between managing form state, validation, error messages, and TypeScript types, there's a lot of boilerplate and room for bugs. This is where React Hook Form and Zod shine together.
The Problem: Traditional Form Pain Points
Let's look at a typical registration form without these tools:
interface RegistrationData {
email: string;
password: string;
confirmPassword: string;
age: number;
}
function RegistrationForm() {
const [formData, setFormData] = useState<RegistrationData>({
email: '',
password: '',
confirmPassword: '',
age: 0,
});
const [errors, setErrors] = useState<Partial<Record<keyof RegistrationData, string>>>({});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newErrors: typeof errors = {};
// Manual validation - easy to get wrong
if (!formData.email.includes('@')) {
newErrors.email = 'Invalid email';
}
if (formData.password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = 'Passwords must match';
}
if (formData.age < 18) {
newErrors.age = 'Must be 18 or older';
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
// Submit to backend
api.register(formData);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
{errors.email && <span>{errors.email}</span>}
{/* Repeat for other fields... */}
</form>
);
}
Problems with this approach:
- Type duplication: We define
RegistrationDatafor TypeScript, then duplicate validation logic - No schema sharing: Backend needs its own validation with different rules
- Manual state management: Verbose onChange handlers for each field
- Error-prone: Easy to forget validation rules or mistype field names
- Poor DX: No autocomplete for nested fields, errors discovered at runtime
The Solution: React Hook Form + Zod
Here's the same form with React Hook Form and Zod:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// Single source of truth for validation and types
const registrationSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string(),
age: z.number().min(18, 'Must be 18 or older'),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords must match",
path: ["confirmPassword"],
});
// Automatically infer TypeScript type from schema
type RegistrationData = z.infer<typeof registrationSchema>;
function RegistrationForm() {
const { register, handleSubmit, formState: { errors } } = useForm<RegistrationData>({
resolver: zodResolver(registrationSchema),
});
const onSubmit = (data: RegistrationData) => {
// Data is validated and type-safe here
api.register(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input type="email" {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<input type="password" {...register('confirmPassword')} />
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
<input type="number" {...register('age', { valueAsNumber: true })} />
{errors.age && <span>{errors.age.message}</span>}
<button type="submit">Register</button>
</form>
);
}
What improved:
- ✅ Single schema definition: Validation rules and types in one place
- ✅ Type inference:
z.infer<typeof schema>generates TypeScript types automatically - ✅ Minimal boilerplate:
register()handles onChange, onBlur, ref - ✅ Built-in validation: Runs automatically on submit
- ✅ Better error handling: Type-safe error messages
The Real Power: Schema Sharing
The killer feature is sharing schemas between frontend and backend. Create a shared package:
// packages/shared/schemas/user.schema.ts
import { z } from 'zod';
export const registrationSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string(),
age: z.number().min(18, 'Must be 18 or older'),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords must match",
path: ["confirmPassword"],
});
// For backend: omit confirmPassword since we don't store it
export const userCreateSchema = registrationSchema.omit({ confirmPassword: true });
export type RegistrationInput = z.infer<typeof registrationSchema>;
export type UserCreate = z.infer<typeof userCreateSchema>;
Frontend usage:
// app/components/RegistrationForm.tsx
import { registrationSchema } from '@shared/schemas/user.schema';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
function RegistrationForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(registrationSchema),
});
// ... rest of component
}
Backend usage (Express):
// server/routes/auth.ts
import { userCreateSchema } from '@shared/schemas/user.schema';
import express from 'express';
router.post('/register', async (req, res) => {
try {
// Parse and validate request body
const userData = userCreateSchema.parse(req.body);
// userData is now type-safe and validated
const user = await db.users.create(userData);
res.json({ user });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ errors: error.errors });
}
throw error;
}
});
Benefits:
- DRY principle: Write validation once, use everywhere
- Type safety: End-to-end TypeScript types from form to database
- Consistency: Frontend and backend always validate the same way
- Refactoring confidence: Change schema once, TypeScript catches all issues
- Less bugs: Impossible to have frontend/backend validation mismatch
Advanced Patterns
Dynamic Fields with Arrays
const projectSchema = z.object({
name: z.string().min(1, 'Project name required'),
teamMembers: z.array(z.object({
name: z.string().min(1),
role: z.enum(['developer', 'designer', 'manager']),
email: z.string().email(),
})).min(1, 'At least one team member required'),
});
type Project = z.infer<typeof projectSchema>;
function ProjectForm() {
const { register, handleSubmit, formState: { errors } } = useForm<Project>({
resolver: zodResolver(projectSchema),
defaultValues: {
teamMembers: [{ name: '', role: 'developer', email: '' }],
},
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
{/* Array fields with type safety */}
<input {...register('teamMembers.0.name')} />
<input {...register('teamMembers.0.email')} />
<select {...register('teamMembers.0.role')}>
<option value="developer">Developer</option>
<option value="designer">Designer</option>
<option value="manager">Manager</option>
</select>
</form>
);
}
Conditional Validation
const accountSchema = z.discriminatedUnion('accountType', [
z.object({
accountType: z.literal('personal'),
name: z.string(),
}),
z.object({
accountType: z.literal('business'),
name: z.string(),
companyName: z.string().min(1, 'Company name required'),
taxId: z.string().min(1, 'Tax ID required'),
}),
]);
// TypeScript knows which fields exist based on accountType
type Account = z.infer<typeof accountSchema>;
Transform and Preprocess
const searchSchema = z.object({
// Transform comma-separated string to array
tags: z.string().transform((val) => val.split(',').map(s => s.trim())),
// Preprocess empty string to undefined for optional fields
maxPrice: z.preprocess(
(val) => val === '' ? undefined : Number(val),
z.number().positive().optional()
),
});
Performance Considerations
React Hook Form uses uncontrolled inputs by default, minimizing re-renders:
// Uncontrolled (default) - minimal re-renders
const { register } = useForm();
<input {...register('email')} />
// Controlled - re-renders on every keystroke
const { watch, register } = useForm();
const email = watch('email');
<input {...register('email')} value={email} />
For large forms, this makes a significant performance difference.
Integration with React Query
Combine with React Query for complete data flow:
import { useMutation } from '@tanstack/react-query';
function RegistrationForm() {
const { register, handleSubmit, setError } = useForm({
resolver: zodResolver(registrationSchema),
});
const registerMutation = useMutation({
mutationFn: (data: RegistrationInput) => api.register(data),
onError: (error) => {
// Handle backend validation errors
if (error.response?.data?.errors) {
error.response.data.errors.forEach((err) => {
setError(err.path, { message: err.message });
});
}
},
});
const onSubmit = (data: RegistrationInput) => {
registerMutation.mutate(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* form fields */}
<button type="submit" disabled={registerMutation.isPending}>
{registerMutation.isPending ? 'Registering...' : 'Register'}
</button>
</form>
);
}
Conclusion
React Hook Form + Zod transforms form development from a tedious, error-prone task into a streamlined, type-safe experience. The key benefits:
- Type inference: Write schemas, get TypeScript types for free
- Schema sharing: One source of truth for frontend and backend
- Less boilerplate: Eliminate manual state management
- Better validation: Declarative, composable validation rules
- Performance: Uncontrolled inputs minimize re-renders
The combination creates a development experience where the type system guides you, validation is automatic, and refactoring is safe. Once you experience the confidence of end-to-end type safety from form to database, it's hard to go back.
Resources
- React Hook Form Documentation
- Zod Documentation
- @hookform/resolvers - Validation resolvers for various libraries