lenec ru

← все посты

react-hook-form + zod: типизированные формы без боли

10K

Когда я последний раз делала форму с нуля без библиотек, это был 2021 год, и через две недели я переписала всё на react-hook-form. С тех пор каждая нетривиальная форма в моих проектах живёт на rhf, а валидация — на zod. Расскажу, как я их соединяю, какие настройки реально нужны, и где их связка обламывается.

Почему именно эта связка

react-hook-form держит форму в неконтролируемом виде через refs. Это означает: ты не дёргаешь стейт-апдейт на каждое нажатие клавиши, и при сложных формах с десятками полей нет лагов на ввод. Zod даёт схему, из которой выводится TypeScript-тип. Между ними мост — @hookform/resolvers/zod.

На бумаге звучит сложно. На деле — три-четыре строки на форму.

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

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

type FormValues = z.infer<typeof schema>;

export function LoginForm() {
  const { register, handleSubmit, formState: { errors, isSubmitting } } =
    useForm<FormValues>({ resolver: zodResolver(schema) });

  const onSubmit = async (data: FormValues) => {
    await api.login(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}
      <input type="password" {...register("password")} />
      {errors.password && <p>{errors.password.message}</p>}
      <button disabled={isSubmitting}>Войти</button>
    </form>
  );
}

Тип FormValues выведен из схемы. Не нужно отдельно описывать TS-тип и валидатор — они синхронизированы автоматически. Это первая причина, почему я не возвращаюсь к Yup.

Когда форма посложнее

useFieldArray для динамических списков

Форма с динамическими элементами (добавь телефон, удали телефон) на голом стейте — пытка. На rhf — нормальный API.

const schema = z.object({
  name: z.string().min(1),
  phones: z
    .array(z.object({ number: z.string().min(5) }))
    .min(1, "Хотя бы один телефон"),
});

const { control, register, handleSubmit, formState: { errors } } =
  useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema) });

const { fields, append, remove } = useFieldArray({ control, name: "phones" });

return (
  <form onSubmit={handleSubmit(onSubmit)}>
    <input {...register("name")} />
    {fields.map((field, i) => (
      <div key={field.id}>
        <input {...register(`phones.${i}.number`)} />
        <button type="button" onClick={() => remove(i)}>×</button>
      </div>
    ))}
    <button type="button" onClick={() => append({ number: "" })}>+</button>
  </form>
);

Важно: field.id — это внутренний key от rhf, не реальный id из БД. Не пытайся хитро использовать его как доменное поле. Под доменный id заводи отдельное поле в схеме.

Зависимые поля: watch и setValue

«Если выбран тип А — покажи поле X, если Б — покажи Y». На rhf делается через watch:

const type = watch("type");

return (
  <form>
    <select {...register("type")}>
      <option value="individual">Физлицо</option>
      <option value="company">Компания</option>
    </select>
    {type === "company" && (
      <input {...register("inn")} placeholder="ИНН" />
    )}
  </form>
);

На стороне zod к таким формам прикрутишь discriminatedUnion — увидишь ниже.

Discriminated unions в zod

Когда у формы реально разные «варианты» с разным набором полей, не обмазывай схему z.optional() везде. Используй размеченное объединение:

const schema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("individual"),
    fullName: z.string().min(2),
  }),
  z.object({
    type: z.literal("company"),
    companyName: z.string().min(2),
    inn: z.string().regex(/^\d{10,12}$/),
  }),
]);

type FormValues = z.infer<typeof schema>;

На выходе TypeScript понимает, что если type === "company", то inn существует и обязателен. На обработчике сабмита можешь сразу делать switch (data.type) с полным narrowing.

Грабля при подключении к rhf

До недавних версий rhf плохо дружил с discriminated unions: тип FormValues был объединением, и register("inn") ругался, что поля нет в одном из вариантов. На свежих версиях это работает чище, но если ловишь странные ошибки типов — проверь, что у тебя rhf и zod в актуальных мажорных версиях.

Серверная валидация без дублей

Та же схема zod на клиенте и на сервере. Не дублируй валидацию. На клиенте — zodResolver, на сервере — schema.safeParse(body). Если форма отправляется через Server Action, использовать буквально один и тот же файл с обеих сторон:

// shared/schema.ts
export const userSchema = z.object({ /* ... */ });
export type UserInput = z.infer<typeof userSchema>;

// app/actions.ts
"use server";
import { userSchema } from "@/shared/schema";

export async function createUser(formData: FormData) {
  const parsed = userSchema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }
  await db.users.create({ data: parsed.data });
  return { ok: true };
}

В одном проекте у нас валидация была отдельно на rhf через zod и отдельно на бэке через class-validator. Раз в месяц что-то расходилось — и на ревью кто-то тратил час, чтобы найти, где. После переезда на единый zod-файл такие баги исчезли.

Контролируемые компоненты внутри

react-hook-form по умолчанию работает с неконтролируемыми инпутами. Если у тебя кастомный селект, который требует value и onChange, оборачивай его в Controller:

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

<Controller
  control={control}
  name="category"
  render={({ field, fieldState }) => (
    <CategorySelect
      value={field.value}
      onChange={field.onChange}
      onBlur={field.onBlur}
      error={fieldState.error?.message}
    />
  )}
/>

Это место, где люди впадают в панику. На самом деле Controller прозрачный: он просто вешает рукоятки rhf на твой контролируемый компонент.

Подводные камни, которые стоили мне времени

1. Преобразование типов на ввод

Инпуты возвращают строки. Если в схеме у тебя z.number(), при сабмите получишь ошибку «Expected number, received string». Решение — z.coerce.number():

const schema = z.object({
  price: z.coerce.number().min(0),
  count: z.coerce.number().int(),
});

Или явно через {...register("price", { valueAsNumber: true })}. Я выбираю первый вариант — схема становится единственным источником истины.

2. Чекбоксы и булевы значения

Пустой чекбокс возвращает false, register справляется. Но если у тебя «галочка как required» — не пиши z.boolean(), пиши z.literal(true, { errorMap: () => ({ message: "Согласие обязательно" }) }). Тогда схема прямо проверит, что галочка стоит.

3. Сброс формы после успешной отправки

После сабмита поля не очищаются автоматически. Если нужно — вызывай reset() в onSubmit:

const onSubmit = async (data: FormValues) => {
  await api.create(data);
  reset();
};

Без reset() у пользователя остаётся заполненная форма — иногда это хочется, иногда нет. Решай явно.

4. Default-значения

Если форма редактирует существующую сущность, передавай defaultValues в useForm. И передавай их сразу — обновление через reset(values) в useEffect работает, но плодит лишний рендер.

const { register } = useForm<FormValues>({
  resolver: zodResolver(schema),
  defaultValues: existingUser,
});

Если данные приходят асинхронно — не маунти форму до того, как они есть. Покажи скелетон, потом смонтируй.

Что запомнить

На связке rhf + zod форма любой сложности укладывается в одну предсказуемую структуру: схема — источник правды, типы выводятся из неё, тот же файл едет на сервер для проверки. Дополнительные хуки (useFieldArray, watch, Controller) закрывают почти всё, что я встречала в проде, кроме редких случаев с виртуализированными формами.

Не пытайся написать свою валидацию руками поверх rhf, если уже знаешь zod. И не используй zod без rhf, если форма больше двух полей: useState на каждое поле — это путь к лагам и багам синхронизации.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.