react-hook-form + zod: типизированные формы без боли
Когда я последний раз делала форму с нуля без библиотек, это был 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 на каждое поле — это путь к лагам и багам синхронизации.