Forms are everywhere in web apps, and they are notoriously hard to get right. React Hook Form handles form state and submission with minimal re-renders. Zod provides runtime validation with TypeScript inference. Together, they create type-safe forms where your validation schema and TypeScript types stay in sync.
Bottom line: Use React Hook Form for form state management — it is fast and has excellent DX. Use Zod for validation schemas — they generate TypeScript types automatically. Connect them with @hookform/resolvers. This pattern handles everything from simple login forms to complex multi-step wizards with proper typing throughout.
Quick setup
npm install react-hook-form zod @hookform/resolvers
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// 1. Define schema
const schema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
// 2. Infer type from schema
type FormData = z.infer<typeof schema>;
// 3. Use in component
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = async (data: FormData) => {
// data is fully typed
console.log(data.email, data.password);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register('email')} />
{errors.email && <p className="error">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" type="password" {...register('password')} />
{errors.password && <p className="error">{errors.password.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Log in'}
</button>
</form>
);
}
Zod schema patterns
Strings
const schema = z.object({
// Required string
name: z.string().min(1, 'Name is required'),
// Email
email: z.string().email('Invalid email'),
// URL
website: z.string().url('Invalid URL'),
// Regex pattern
username: z.string().regex(/^[a-z0-9_]+$/, 'Lowercase letters, numbers, and underscores only'),
// Length constraints
bio: z.string().max(500, 'Bio must be 500 characters or less'),
// Optional
nickname: z.string().optional(),
// Optional with default
role: z.string().default('user'),
// Trim whitespace
title: z.string().trim().min(1, 'Title is required'),
});
Numbers
const schema = z.object({
age: z.number().min(18, 'Must be 18 or older').max(120),
price: z.number().positive('Price must be positive'),
quantity: z.number().int('Must be a whole number').min(1),
// From string input (HTML inputs are strings)
amount: z.coerce.number().positive(),
});
Dates
const schema = z.object({
// Date object
birthDate: z.date(),
// From string input
startDate: z.coerce.date(),
// With constraints
eventDate: z.date().min(new Date(), 'Date must be in the future'),
});
Enums and unions
const schema = z.object({
// Enum
status: z.enum(['draft', 'published', 'archived']),
// Union
contactMethod: z.union([
z.literal('email'),
z.literal('phone'),
z.literal('mail'),
]),
// Discriminated union
notification: z.discriminatedUnion('type', [
z.object({ type: z.literal('email'), address: z.string().email() }),
z.object({ type: z.literal('sms'), phone: z.string() }),
]),
});
Arrays and objects
const schema = z.object({
// Array of strings
tags: z.array(z.string()).min(1, 'At least one tag required'),
// Array of objects
addresses: z.array(z.object({
street: z.string(),
city: z.string(),
zip: z.string(),
})).max(3, 'Maximum 3 addresses'),
// Nested object
profile: z.object({
firstName: z.string(),
lastName: z.string(),
}),
});
Conditional validation
const schema = z.object({
hasCompany: z.boolean(),
companyName: z.string().optional(),
}).refine(
(data) => !data.hasCompany || data.companyName,
{
message: 'Company name is required when has company is checked',
path: ['companyName'],
}
);
// Or using superRefine for multiple conditions
const schema = z.object({
password: z.string(),
confirmPassword: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Passwords do not match',
path: ['confirmPassword'],
});
}
});
const {
register, // Connect inputs
handleSubmit, // Wrap submit handler
formState: {
errors, // Validation errors
isSubmitting, // Submit in progress
isValid, // All fields valid
isDirty, // Any field changed
dirtyFields, // Which fields changed
touchedFields, // Which fields touched
},
watch, // Watch field values
setValue, // Set field value programmatically
reset, // Reset form
setError, // Set error manually
clearErrors, // Clear errors
trigger, // Trigger validation
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
email: '',
password: '',
},
mode: 'onBlur', // Validate on blur
});
Validation modes
useForm({
mode: 'onSubmit', // Validate on submit (default)
mode: 'onBlur', // Validate on blur
mode: 'onChange', // Validate on change
mode: 'onTouched', // Validate on blur, then on change
mode: 'all', // Validate on blur and change
});
Watching values
function Form() {
const { register, watch } = useForm<FormData>();
// Watch single field
const email = watch('email');
// Watch multiple fields
const [firstName, lastName] = watch(['firstName', 'lastName']);
// Watch all fields
const allValues = watch();
// Watch with callback (for side effects)
useEffect(() => {
const subscription = watch((value, { name, type }) => {
console.log(name, value);
});
return () => subscription.unsubscribe();
}, [watch]);
return <form>...</form>;
}
Default values
// Static defaults
useForm({
defaultValues: {
email: '',
role: 'user',
},
});
// Async defaults (loading existing data)
useForm({
defaultValues: async () => {
const user = await fetchUser();
return {
email: user.email,
name: user.name,
};
},
});
// Reset with new values
const { reset } = useForm();
useEffect(() => {
if (userData) {
reset(userData);
}
}, [userData, reset]);
Server-side errors
const { setError } = useForm<FormData>();
const onSubmit = async (data: FormData) => {
try {
await api.register(data);
} catch (error) {
if (error.code === 'EMAIL_EXISTS') {
setError('email', {
type: 'server',
message: 'This email is already registered',
});
} else {
setError('root.serverError', {
message: 'Something went wrong. Please try again.',
});
}
}
};
// Display root error
{errors.root?.serverError && (
<div className="alert">{errors.root.serverError.message}</div>
)}
Dynamic fields (useFieldArray)
import { useForm, useFieldArray } from 'react-hook-form';
const schema = z.object({
users: z.array(z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
})).min(1, 'At least one user required'),
});
type FormData = z.infer<typeof schema>;
function UsersForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
users: [{ name: '', email: '' }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'users',
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`users.${index}.name`)} placeholder="Name" />
{errors.users?.[index]?.name && (
<p>{errors.users[index].name.message}</p>
)}
<input {...register(`users.${index}.email`)} placeholder="Email" />
{errors.users?.[index]?.email && (
<p>{errors.users[index].email.message}</p>
)}
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button type="button" onClick={() => append({ name: '', email: '' })}>
Add User
</button>
<button type="submit">Submit</button>
</form>
);
}
Controlled components
For custom components or UI libraries:
import { Controller } from 'react-hook-form';
function Form() {
const { control, handleSubmit } = useForm<FormData>();
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="category"
control={control}
render={({ field, fieldState: { error } }) => (
<>
<Select
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
options={categoryOptions}
/>
{error && <p className="error">{error.message}</p>}
</>
)}
/>
</form>
);
}
With UI libraries (shadcn/ui)
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
function LoginForm() {
const form = useForm<FormData>({
resolver: zodResolver(schema),
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="ada@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
}
const stepSchemas = [
z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
}),
z.object({
email: z.string().email(),
phone: z.string().optional(),
}),
z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}),
];
function MultiStepForm() {
const [step, setStep] = useState(0);
const [formData, setFormData] = useState({});
const currentSchema = stepSchemas[step];
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(currentSchema),
defaultValues: formData,
});
const onNext = (data) => {
setFormData((prev) => ({ ...prev, ...data }));
if (step < stepSchemas.length - 1) {
setStep((s) => s + 1);
} else {
// Final submit
submitForm({ ...formData, ...data });
}
};
const onBack = () => setStep((s) => s - 1);
return (
<form onSubmit={handleSubmit(onNext)}>
{step === 0 && (
<>
<input {...register('firstName')} />
<input {...register('lastName')} />
</>
)}
{step === 1 && (
<>
<input {...register('email')} />
<input {...register('phone')} />
</>
)}
{step === 2 && (
<>
<input type="password" {...register('password')} />
<input type="password" {...register('confirmPassword')} />
</>
)}
{step > 0 && <button type="button" onClick={onBack}>Back</button>}
<button type="submit">{step === 2 ? 'Submit' : 'Next'}</button>
</form>
);
}
File uploads
const schema = z.object({
avatar: z
.instanceof(FileList)
.refine((files) => files.length > 0, 'File is required')
.refine((files) => files[0]?.size <= 5_000_000, 'Max file size is 5MB')
.refine(
(files) => ['image/jpeg', 'image/png'].includes(files[0]?.type),
'Only JPEG and PNG are allowed'
),
});
function AvatarUpload() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
});
const onSubmit = async (data) => {
const formData = new FormData();
formData.append('avatar', data.avatar[0]);
await uploadAvatar(formData);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input type="file" accept="image/*" {...register('avatar')} />
{errors.avatar && <p>{errors.avatar.message}</p>}
<button type="submit">Upload</button>
</form>
);
}
Use the Fake Data Generator to create test data. Validate your schema logic with JSON Validator. Test regex patterns with Regex Tester.
// Testing with React Testing Library
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('shows validation errors', async () => {
render(<LoginForm />);
await userEvent.click(screen.getByRole('button', { name: /log in/i }));
await waitFor(() => {
expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
expect(screen.getByText(/password must be at least 8 characters/i)).toBeInTheDocument();
});
});
test('submits valid form', async () => {
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await userEvent.type(screen.getByLabelText(/email/i), 'ada@example.com');
await userEvent.type(screen.getByLabelText(/password/i), 'password123');
await userEvent.click(screen.getByRole('button', { name: /log in/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: 'ada@example.com',
password: 'password123',
});
});
});
Practical takeaway
React Hook Form + Zod is the modern standard for form handling in React. Define your schema once, get TypeScript types and validation for free. Use register for native inputs, Controller for custom components. Handle async validation with refine. Use useFieldArray for dynamic lists. Keep schemas in separate files for reuse. Test validation logic separately from UI.
Sources