← назад к разделу

Форма — один из самых рутинных элементов интерфейса. Казалось бы, несколько полей и кнопка. Но за ними стоит задача: собрать ввод, проверить его, показать ошибки — и отправить на сервер именно в том формате, который тот ждёт.

Что не так с наивным подходом

Первое, что приходит в голову — хранить каждое поле в useState и проверять всё вручную:

const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [emailError, setEmailError] = useState("");

function validate() {
  if (!email.includes("@")) setEmailError("Некорректный email");
  // и так для каждого поля...
}

На форме из 3–4 полей это ещё терпимо. На форме из 10–15 полей это превращается в набор разрозненного кода: состояний столько же, сколько полей, плюс столько же для ошибок, плюс логика проверки раскидана по файлу. Любое изменение поля перерисовывает компонент целиком — даже если изменилось только одно поле из пятнадцати.

Связка react-hook-form + zod решает это иначе: управление полями и правила проверки живут отдельно от useState, и каждый слой делает своё дело.

react-hook-form: поля без лишних перерисовок

react-hook-form регистрирует поля через register и работает с ними как с неконтролируемыми — то есть не хранит значение каждого поля в React-состоянии. DOM сам хранит текущее значение, библиотека читает его при сабмите. За счёт этого ввод в одно поле не перерисовывает соседние.

import { useForm } from "react-hook-form";

type LoginForm = { email: string; password: string };

function Login() {
  const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>();

  const onSubmit = (data: LoginForm) => {
    // data типизирована — email и password уже строки
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} />
      {errors.email && <span>{errors.email.message}</span>}

      <input type="password" {...register("password")} />
      {errors.password && <span>{errors.password.message}</span>}

      <button type="submit">Войти</button>
    </form>
  );
}

handleSubmit перехватывает отправку, собирает значения всех полей и вызывает ваш колбэк — но только если форма прошла валидацию. Ошибки полей доступны через formState.errors.

zod: правила проверки в одном месте

useState-подход прячет правила проверки внутри функций — и они легко расходятся с тем, что на самом деле принимает сервер. zod позволяет описать форму данных декларативно, в виде схемы:

import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

const loginSchema = z.object({
  email: z.string().email("Введите корректный email"),
  password: z.string().min(8, "Минимум 8 символов"),
});

type LoginForm = z.infer<typeof loginSchema>; // тип выведется из схемы автоматически

function Login() {
  const { register, handleSubmit, formState: { errors } } =
    useForm<LoginForm>({ resolver: zodResolver(loginSchema) });

  // ...
}

z.infer<typeof schema> выводит TypeScript-тип прямо из схемы — тип и правила не расходятся, потому что они одно и то же. zodResolver подключает схему к форме: react-hook-form при сабмите прогоняет значения через неё и заполняет formState.errors сообщениями из схемы.

Пример чуть сложнее — форма создания продукта:

const createProductSchema = z.object({
  name: z.string().min(1, "Укажите название").max(200),
  price: z.number().int().positive("Цена должна быть больше нуля"),
  category: z.enum(["electronics", "clothing", "food"]),
});

type CreateProductForm = z.infer<typeof createProductSchema>;

Ошибки от сервера

Клиентская валидация ловит очевидные ошибки до отправки — опечатки, пустые обязательные поля, неверный формат. Но сервер может вернуть ошибку, которую клиент не может проверить заранее: «email уже занят», «такой артикул существует», «недостаточно прав».

react-hook-form решает это через setError — ошибку можно установить программно, и она появится рядом с нужным полем:

const { register, handleSubmit, formState: { errors }, setError } = useForm<LoginForm>({
  resolver: zodResolver(loginSchema),
});

const onSubmit = async (data: LoginForm) => {
  try {
    await login(data);
  } catch (err) {
    if (err.code === "EMAIL_NOT_FOUND") {
      setError("email", { message: "Пользователь с таким email не найден" });
    }
  }
};

Это важная деталь архитектуры: клиентская валидация — для быстрой обратной связи, серверная — источник истины. Обе нужны: клиент реагирует моментально, сервер гарантирует корректность.

Числа и преобразование типов

Одна не очевидная вещь: HTML-поля всегда возвращают строки. Если поле <input type="number" />, значение в data всё равно придёт строкой — и zod выдаст ошибку типа, если схема ожидает z.number().

Решение — z.coerce.number(): zod сам конвертирует строку в число перед проверкой:

const schema = z.object({
  price: z.coerce.number().positive("Цена должна быть больше нуля"),
  quantity: z.coerce.number().int().min(1),
});

Значения по умолчанию

Для форм редактирования — когда нужно загрузить существующие данные и дать пользователю их изменить — useForm принимает defaultValues:

const { register, handleSubmit } = useForm<ProductForm>({
  resolver: zodResolver(productSchema),
  defaultValues: {
    name: product.name,
    price: product.price,
  },
});

Если данные загружаются асинхронно, можно передать defaultValues позже через reset:

useEffect(() => {
  if (product) reset(product);
}, [product]);

Коротко

  • Наивный подход (поле → useState, ошибки → отдельный useState) разрастается пропорционально числу полей.
  • react-hook-form работает с неконтролируемыми полями — ввод в одно поле не перерисовывает остальные.
  • zod описывает правила проверки декларативно; z.infer выводит TypeScript-тип из той же схемы.
  • zodResolver соединяет схему с формой; ошибки схемы попадают в formState.errors.
  • Ошибки сервера устанавливают через setError — они появляются рядом с нужным полем.
  • z.coerce.number() конвертирует строку из <input> в число перед проверкой.
  • Для форм редактирования — defaultValues при создании или reset после загрузки данных.
  • Клиентская валидация — быстрая обратная связь; серверная — источник истины. Обе нужны.

Что почитать дальше

  • Загрузка данных: TanStack Query — как отправить данные формы мутацией и обработать ответ сервера.
  • Структура проекта — где держать zod-схемы и как они связываются с типами запросов.
  • Роутинг: React Router — куда перейти после успешной отправки формы.