Формы в React: React Hook Form и валидация через Zod
Практика3 минРедакция Innovialabs
Коротко
React Hook Form управляет полями и отправкой без лишних перерисовок, а Zod-схема описывает правила валидации один раз — и ту же схему можно использовать на сервере для проверки данных.
Почему эта связка
React Hook Form хранит значения полей вне React-состояния и не перерисовывает всю форму на каждое нажатие клавиши. Zod описывает данные схемой: типы, ограничения и тексты ошибок в одном месте. Связывает их пакет @hookform/resolvers.
Главный выигрыш — одна схема на клиенте и сервере. Правила не расходятся, а TypeScript-тип формы выводится из схемы автоматически.
npm install react-hook-form zod @hookform/resolvers
Шаг 1. Схема с понятными сообщениями
Соберём форму заявки: имя, телефон в узбекском формате и комментарий.
// 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, 'Введите имя — минимум 2 символа'),
phone: z
.string()
.refine(
(v) => UZ_PHONE.test(v.replace(/[\s()-]/g, '')),
'Номер в формате +998 90 123 45 67',
),
message: z.string().max(1000, 'Не больше 1000 символов').optional(),
});
export type Lead = z.infer<typeof leadSchema>;
Что здесь важно:
- сообщения на языке пользователя передаются вторым аргументом;
- телефон проверяется после удаления пробелов, скобок и дефисов, поэтому
+998 (90) 123-45-67тоже пройдёт; - узбекский номер — это
+998и ещё 9 цифр (код оператора и номер абонента).
Шаг 2. Регистрация полей
'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="Имя" />
{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 ? 'Отправляем…' : 'Отправить'}
</button>
</form>
);
}
register подключает поле к форме, errors содержит тексты из схемы, isSubmitting блокирует кнопку от двойной отправки. Атрибут noValidate отключает встроенные подсказки браузера, чтобы не было двух видов ошибок.
Шаг 3. Асинхронная отправка и ошибки сервера
Функцию onSubmit объявите внутри компонента — ей нужны setError и 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: 'Не удалось отправить. Попробуйте ещё раз.' });
}
return;
}
reset();
}
handleSubmit вызывает onSubmit только если клиентская валидация прошла. Ошибки сервера раскладываются по полям через setError, а общая ошибка попадает в root.server.
Шаг 4. Та же схема на сервере
Клиентской проверке доверять нельзя: запрос можно отправить в обход формы. Пример для Route Handler в Next.js:
// 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 уже проверен и типизирован — сохраняем заявку
return Response.json({ ok: true });
}
Схема лежит в общем файле, поэтому изменение правила сразу действует и в браузере, и на сервере.
Частые ошибки
- Проверять данные только на клиенте.
- Хранить значения каждого поля в
useStateпараллельно с React Hook Form. - Писать правила дважды: в
register({ required })и в схеме. - Забывать блокировать кнопку во время отправки.
- Показывать сырые английские сообщения Zod вместо своих текстов.