Why React Hook Form
React Hook Form is a library for working with forms that minimizes re-renders and simplifies validation. Combined with Zod it gives a powerful type-safe form system.
A basic form
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// Zod schema
const loginSchema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(8, 'At least 8 characters'),
rememberMe: z.boolean().optional()
});
type LoginFormData = z.infer<typeof loginSchema>;
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
rememberMe: false
}
});
const onSubmit = async (data: LoginFormData) => {
console.log(data);
// API request
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input
{...register('email')}
type="email"
placeholder="Email"
/>
{errors.email && (
<span className="error">{errors.email.message}</span>
)}
</div>
<div>
<input
{...register('password')}
type="password"
placeholder="Password"
/>
{errors.password && (
<span className="error">{errors.password.message}</span>
)}
</div>
<label>
<input {...register('rememberMe')} type="checkbox" />
Remember me
</label>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
</form>
);
}
Complex Zod schemas
// Registration with password confirmation
const registerSchema = z.object({
name: z.string().min(2, 'At least 2 characters'),
email: z.string().email('Invalid email'),
password: z
.string()
.min(8, 'At least 8 characters')
.regex(/[A-Z]/, 'An uppercase letter is required')
.regex(/[0-9]/, 'A number is required'),
confirmPassword: z.string(),
age: z.number().min(18, 'At least 18 years old').optional(),
role: z.enum(['user', 'admin']),
terms: z.literal(true, {
errorMap: () => ({ message: 'Accept the terms' })
})
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
});
// Conditional validation
const orderSchema = z.object({
deliveryType: z.enum(['pickup', 'delivery']),
address: z.string().optional()
}).refine((data) => {
if (data.deliveryType === 'delivery') {
return data.address && data.address.length > 5;
}
return true;
}, {
message: 'Enter a delivery address',
path: ['address']
});
// Arrays and nested objects
const productSchema = z.object({
name: z.string().min(1),
variants: z.array(z.object({
size: z.string(),
price: z.number().positive(),
stock: z.number().int().min(0)
})).min(1, 'Add at least one variant')
});
Controlled fields
import { useForm, Controller } from 'react-hook-form';
import { Select, DatePicker, Slider } from './ui';
function ProductForm() {
const { control, handleSubmit } = useForm<ProductFormData>({
resolver: zodResolver(productSchema)
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Custom Select */}
<Controller
name="category"
control={control}
render={({ field, fieldState }) => (
<Select
{...field}
options={categories}
error={fieldState.error?.message}
/>
)}
/>
{/* DatePicker */}
<Controller
name="publishDate"
control={control}
render={({ field }) => (
<DatePicker
selected={field.value}
onChange={field.onChange}
/>
)}
/>
{/* Slider */}
<Controller
name="price"
control={control}
render={({ field }) => (
<Slider
value={field.value}
onChange={field.onChange}
min={0}
max={10000}
/>
)}
/>
</form>
);
}
Dynamic fields (useFieldArray)
import { useForm, useFieldArray } from 'react-hook-form';
function OrderForm() {
const { register, control, handleSubmit } = useForm<OrderFormData>({
defaultValues: {
items: [{ name: '', quantity: 1 }]
}
});
const { fields, append, remove } = useFieldArray({
control,
name: 'items'
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
<input
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
type="number"
/>
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button type="button" onClick={() => append({ name: '', quantity: 1 })}>
Add item
</button>
<button type="submit">Place order</button>
</form>
);
}
Handling server errors
function RegistrationForm() {
const { setError, handleSubmit } = useForm<FormData>({
resolver: zodResolver(schema)
});
const onSubmit = async (data: FormData) => {
try {
await registerUser(data);
} catch (error) {
if (error.code === 'EMAIL_EXISTS') {
setError('email', {
type: 'server',
message: 'Email is already registered'
});
} else {
setError('root', {
type: 'server',
message: 'An error occurred, please try again later'
});
}
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root && <div className="error">{errors.root.message}</div>}
{/* form fields */}
</form>
);
}
Related articles
Conclusion
React Hook Form + Zod is a powerful combination for type-safe forms. Minimal re-renders, declarative validation, excellent TypeScript support. Use Controller for custom components and useFieldArray for dynamic fields.
Client-side validation is for UX, server-side validation is for security. Use both.



