Перейти к содержанию
Innovialabs

Формы в React: React Hook Form и валидация через Zod

Практика3 мин

Коротко

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 вместо своих текстов.

Частые вопросы

Нет, всё работает и в JavaScript. Но с TypeScript вы получаете тип формы из схемы бесплатно, и опечатки в именах полей ловятся при сборке.

Поможем с этим

Если хотите не разбираться самим, а получить результат — этим мы занимаемся каждый день.

Обсудить проект

Читайте также

Есть идея или задача? Расскажите нам

Ответим в течение рабочего дня и предложим решение. Консультация бесплатная.