ФАЗА 38. ДАННЫЕ И СОСТОЯНИЕ Формы

Обязательно

Поля, схема и ошибки сервера образуют один контракт

react-hook-form · zod · Validation

react-hook-form управляет полями без лишних рендеров, zod проверяет значения, серверные ошибки возвращаются к конкретным полям.

1. Отдельный useState для каждого поля разрастается

1 / 5

Ручные handlers дублируются, рендер проходит всю форму, правила и ошибки оказываются в разных местах.

  • Изменение имеет общий механизм.
  • Поле регистрируется.
  • Значения собираются при submit.
Множество useStateЕдиный form state

2. register связывает поле с формой

2 / 5

react-hook-form хранит состояние и touched, handleSubmit проверяет данные перед обработчиком, errors доступен по имени.

  • Uncontrolled input уменьшает рендеры.
  • defaultValues заданы.
  • Submit блокируется при ошибках.
registerhandleSubmitЗначения и errors

3. zod выражает правила схемой

3 / 5

Схема задаёт тип, границы и сообщения, resolver подключает её к форме, вывод TypeScript идёт из того же источника.

  • Email проверяется явно.
  • Число проходит coerce.
  • refine проверяет связь полей.
Zod schemaResolverПроверенные значения

4. Ошибка сервера возвращается к полю

4 / 5

setError связывает код ответа с именем поля или root, общая ошибка формы не маскируется под неверный ввод.

  • Маппинг кодов явный.
  • Фокус идёт к проблеме.
  • Пароль и личные данные не логируются.
Ответ сервераField errorRoot errorПовторный submit

5. Default и Actions не отменяют валидацию

5 / 5

Начальные значения нормализуются, React Actions управляют pending, но сервер повторно проверяет все правила и доступ.

  • Клиентская схема удобна, не достаточна.
  • Pending не даёт повторный submit.
  • Ошибка Action возвращается в состояние.
SubmitСерверная проверкаРезультат или ошибка

Формы в React: react-hook-form и zod

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

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

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

Первое, что приходит в голову — хранить каждое поле в 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>;
пользователь жмёт «Отправить» submit handleSubmit собирает значения из DOM FormData zodResolver прогоняет схему результат проверки ошибки в formState.errors или вызов onSubmit(data)

До onSubmit доходит только форма, прошедшая схему; до этого React-состояние полей не трогается.

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

Клиентская валидация ловит очевидные ошибки до отправки — опечатки, пустые обязательные поля, неверный формат. Но сервер может вернуть ошибку, которую клиент не может проверить заранее: «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 instanceof ApiError && 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]);
Дополнительно: при первом чтении можно пропустить

Глубже: Actions в React 19расширенное

Для простых форм React 19 умеет многое без библиотеки. useActionState оборачивает функцию отправки: она получает данные формы, возвращает новое состояние (например, ошибки полей), а хук отдаёт это состояние, обработчик для атрибута action и флаг isPending:

const [state, formAction, isPending] = useActionState(
  async (_prev: { errors: Record<string, string[]> }, formData: FormData) => {
    const parsed = loginSchema.safeParse(Object.fromEntries(formData));
    if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors };
    await login(parsed.data);
    return { errors: {} };
  },
  { errors: {} },
);

<form action={formAction}>
  <input name="email" />
  {state.errors.email && <span>{state.errors.email[0]}</span>}
  <button disabled={isPending}>Войти</button>
</form>

Поля здесь неконтролируемые от природы: значения читаются из FormData при отправке, а zod-схема проверяет их ровно так же. Кнопке внутри формы доступен useFormStatus() с тем же pending, поэтому общий компонент кнопки отправки не нуждается в пропсах. Где по-прежнему нужен react-hook-form: проверка поля по мере ввода, зависимые поля, массивы полей и большие формы с десятками правил. Для формы входа или подписки на рассылку Actions хватает.

Коротко

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

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