Forms in React with React Hook Form and Zod Validation
Practice4 minInnovialabs editorial team
In short
React Hook Form manages fields and submission without extra re-renders, while a Zod schema defines validation rules once, and the same schema can validate data on the server.
Why this pairing
React Hook Form keeps field values outside React state and does not re-render the whole form on every keystroke. Zod describes data as a schema: types, constraints and error messages in one place. The @hookform/resolvers package connects the two.
The main benefit is one schema on the client and the server. Rules never drift apart, and the form's TypeScript type is inferred from the schema automatically.
npm install react-hook-form zod @hookform/resolvers
Step 1. A schema with clear messages
We will build a lead form: name, phone in the Uzbek format and a comment.
// lib/lead-schema.ts
import { z } from 'zod';
const UZ_PHONE = /^\+998\d{9}$/;
export const leadSchema = z.object({
name: z.string().trim().min(2, 'Enter your name, at least 2 characters'),
phone: z
.string()
.refine(
(v) => UZ_PHONE.test(v.replace(/[\s()-]/g, '')),
'Use the format +998 90 123 45 67',
),
message: z.string().max(1000, 'No more than 1000 characters').optional(),
});
export type Lead = z.infer<typeof leadSchema>;
What matters here:
- messages in the user's language are passed as the second argument;
- the phone is checked after removing spaces, brackets and hyphens, so
+998 (90) 123-45-67passes too; - an Uzbek number is
+998followed by 9 digits (operator code and subscriber number).
Step 2. Registering fields
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { leadSchema, type Lead } from '@/lib/lead-schema';
export function LeadForm() {
const {
register,
handleSubmit,
setError,
reset,
formState: { errors, isSubmitting },
} = useForm<Lead>({ resolver: zodResolver(leadSchema) });
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input {...register('name')} placeholder="Name" />
{errors.name && <p role="alert">{errors.name.message}</p>}
<input {...register('phone')} type="tel" placeholder="+998 90 123 45 67" />
{errors.phone && <p role="alert">{errors.phone.message}</p>}
<textarea {...register('message')} />
{errors.message && <p role="alert">{errors.message.message}</p>}
{errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending…' : 'Send'}
</button>
</form>
);
}
register connects a field to the form, errors holds the texts from the schema, and isSubmitting disables the button against double submits. The noValidate attribute turns off native browser hints so users do not see two kinds of errors.
Step 3. Async submit and server errors
Declare onSubmit inside the component, since it needs setError and reset:
async function onSubmit(data: Lead) {
const res = await fetch('/api/lead', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
const fieldErrors: Record<string, string> = body?.fieldErrors ?? {};
const entries = Object.entries(fieldErrors);
if (entries.length) {
for (const [field, message] of entries) {
setError(field as keyof Lead, { message });
}
} else {
setError('root.server', { message: 'Could not send. Please try again.' });
}
return;
}
reset();
}
handleSubmit only calls onSubmit when client validation passes. Server errors are mapped to fields with setError, and a general error goes to root.server.
Step 4. The same schema on the server
Never trust client-side checks alone: a request can bypass the form. Here is a Next.js Route Handler:
// app/api/lead/route.ts
import { leadSchema } from '@/lib/lead-schema';
export async function POST(req: Request) {
const result = leadSchema.safeParse(await req.json().catch(() => null));
if (!result.success) {
const fieldErrors: Record<string, string> = {};
for (const issue of result.error.issues) {
const key = String(issue.path[0] ?? 'form');
fieldErrors[key] ??= issue.message;
}
return Response.json({ fieldErrors }, { status: 400 });
}
// result.data is validated and typed, save the lead
return Response.json({ ok: true });
}
The schema lives in a shared file, so changing a rule takes effect in both the browser and the server at once.
Common mistakes
- Validating only on the client.
- Keeping every field in
useStatealongside React Hook Form. - Writing rules twice: in
register({ required })and in the schema. - Forgetting to disable the button while submitting.
- Showing raw default Zod messages instead of your own texts.