Форма — один из самых рутинных элементов интерфейса. Казалось бы, несколько полей и кнопка. Но за ними стоит задача: собрать ввод, проверить его, показать ошибки — и отправить на сервер именно в том формате, который тот ждёт.
Что не так с наивным подходом
Первое, что приходит в голову — хранить каждое поле в 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 — куда перейти после успешной отправки формы.