Форма — главный converting-элемент почти любого сайта. От лида на лендинге до сложного многоэтапного оформления заказа. И именно формы — самая частая причина потерянных клиентов: «не отправилось», «не понял, что не так», «опять всё стерлось». Разбираем стек, который мы используем по умолчанию: react-hook-form + Zod + Server Actions.
Почему именно эти инструменты
| Инструмент | Зачем |
|---|---|
| react-hook-form | минимум ререндеров, простой API, маленький размер (~9 КБ) |
| Zod | runtime + TypeScript-валидация одной схемой |
| Server Actions | без отдельного API-роута, типизация end-to-end |
| @hookform/resolvers | мост между RHF и Zod |
Альтернативы: Formik (тяжелее, медленнее), Yup (нет нормального вывода типов), плюс самописный fetch — больше кода, выше шанс ошибок.
Минимальная форма
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
name: z.string().min(2, "Минимум 2 символа").max(80),
email: z.string().email("Некорректный email"),
phone: z.string().regex(/^\+?\d[\d\s\-()]{6,20}$/, "Некорректный телефон"),
message: z.string().max(2000).optional(),
consent: z.literal(true, { errorMap: () => ({ message: "Нужно согласие" }) }),
});
type FormValues = z.infer<typeof schema>;
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<FormValues>({ resolver: zodResolver(schema) });
const onSubmit = async (data: FormValues) => {
const res = await fetch("/api/lead", {
method: "POST",
body: JSON.stringify(data),
});
if (res.ok) reset();
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} aria-invalid={!!errors.name} />
{errors.name && <p>{errors.name.message}</p>}
<input {...register("email")} type="email" />
{errors.email && <p>{errors.email.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Отправка..." : "Отправить"}
</button>
</form>
);
}
Зачем Zod, а не просто string
Тип FormValues выводится из схемы автоматически. Если завтра вы добавите в Zod-схему новое поле — TypeScript подсветит, что в форме его не хватает. Плюс ту же схему можно использовать на сервере:
// app/api/lead/route.ts
import { schema } from "@/lib/schemas/lead";
export async function POST(req: Request) {
const body = await req.json();
const parsed = schema.safeParse(body);
if (!parsed.success) {
return Response.json({ errors: parsed.error.flatten() }, { status: 400 });
}
// ...
}
Одна схема — фронт и бэк. Никаких расхождений.
Server Actions вместо API-роута
В Next.js 15 серверные actions покрывают большинство случаев:
// app/actions.ts
"use server";
import { schema } from "@/lib/schemas/lead";
export async function submitLead(prev: unknown, formData: FormData) {
const data = Object.fromEntries(formData);
const parsed = schema.safeParse(data);
if (!parsed.success) {
return { ok: false, errors: parsed.error.flatten() };
}
await db.lead.create({ data: parsed.data });
return { ok: true };
}
// форма
import { useActionState } from "react";
import { submitLead } from "@/app/actions";
export function Form() {
const [state, formAction, pending] = useActionState(submitLead, null);
return (
<form action={formAction}>
<input name="email" />
{state?.errors?.fieldErrors.email && <p>{state.errors.fieldErrors.email[0]}</p>}
<button disabled={pending}>Отправить</button>
</form>
);
}
useActionState — новый хук React 19, заменяет useFormState. Server action возвращает результат, форма видит ошибки без отдельного fetch.
Проблема с RHF + Server Actions
react-hook-form работает на клиенте, server actions — на сервере. Совместить можно через Controller или setError после ответа:
const onSubmit = handleSubmit(async (data) => {
const result = await submitLead(data);
if (!result.ok) {
for (const [field, messages] of Object.entries(result.errors.fieldErrors)) {
setError(field as any, { message: messages?.[0] });
}
}
});
Так серверные ошибки попадают в форму и показываются под нужным полем.
Маски и нормализация
Телефон лучше нормализовать перед валидацией. Пользователь введёт +7 (999) 123-45-67, а вам в БД нужно +79991234567.
const phoneSchema = z.string()
.transform((s) => s.replace(/[^\d+]/g, ""))
.pipe(z.string().regex(/^\+?\d{10,15}$/));
transform нормализует, pipe валидирует уже очищенное.
Для маски ввода — react-imask или cleave.js. Маска — только UX, валидируйте всё равно по содержимому, не по виду.
Антибот: honeypot и rate limit
Минимум против ботов:
// honeypot — скрытое поле, которое люди не заполнят
<input
name="website"
type="text"
tabIndex={-1}
autoComplete="off"
style={{ position: "absolute", left: "-10000px" }}
/>
На сервере: если website непустое — выкидываем тихо (статус 200, чтобы бот не знал).
Rate limit по IP — отдельный middleware или редис-счётчик: 5 запросов в минуту с одного IP, 50 в час. Не дублируйте лимиты на капче — большинство ботов её не пройдут, но дешёвый rate limit остановит примитивный спам бесплатно.
Для сложных форм добавляйте Yandex SmartCaptcha (российская, бесплатная до 1000 верификаций/мес).
Accessibility
Каждый input — с <label>. Не плейсхолдером, а лейблом, который видно всегда:
<label htmlFor="email">Email</label>
<input id="email" {...register("email")} aria-describedby="email-error" />
<p id="email-error" role="alert">{errors.email?.message}</p>
Ошибки — с role="alert", чтобы скринридер их прочитал. aria-invalid на input при ошибке.
Кнопка submit — <button type="submit">, не <div onClick>. Тогда форма отправляется по Enter.
UX-чеклист
- Лейбл всегда видно (не только плейсхолдер).
- Ошибка появляется после
blur, не на каждой нажатой клавише — иначе раздражает. - Кнопка отправки disabled во время submit и показывает loader.
- После успеха — явное сообщение (toast или замена формы на «Спасибо!»).
- После ошибки сети — конкретное сообщение и кнопка «Повторить», не молчание.
- Поля автозаполняются —
autoComplete="email",autoComplete="tel",autoComplete="name". - На мобильных — правильный
inputMode:numericдля телефона,emailдля email.
Многоступенчатые формы
Для длинных форм (анкета, оформление заказа) разбивайте на шаги. RHF умеет это через useFormContext:
<FormProvider {...methods}>
{step === 1 && <StepContacts />}
{step === 2 && <StepAddress />}
{step === 3 && <StepPayment />}
</FormProvider>
Сохраняйте промежуточное состояние в localStorage — пользователь не потеряет данные при случайной перезагрузке.
Файлы и аплоад
<input type="file"> + Zod:
const schema = z.object({
document: z
.instanceof(File)
.refine((f) => f.size <= 5 * 1024 * 1024, "Максимум 5 МБ")
.refine((f) => ["application/pdf", "image/jpeg"].includes(f.type), "PDF или JPG"),
});
Аплоад большого файла лучше отдельным запросом (multipart/form-data или presigned URL в S3-совместимое хранилище), а не через основной submit формы.
Метрики
Без аналитики не понятно, где люди отваливаются. Минимум:
- Goal
form_view— форма попала во вьюпорт. - Goal
form_field_focus_<name>— пользователь начал заполнять. - Goal
form_validation_error_<name>— какое поле чаще валится. - Goal
form_submit_attempt— нажал отправить. - Goal
form_submit_success— успешно отправилось.
Из соотношения field_focus / submit_success видно, на каком поле теряется аудитория.
Итого
react-hook-form + Zod + Server Actions — стандартный, скучный и надёжный стек. Минимум кода, максимум типизации, валидация в одном месте. Запретите себе писать формы на голом useState — это путь к багам и дубликатам.
Частые вопросы
Когда не нужен react-hook-form, а хватит useState?
Если в форме 1-2 поля и нет сложной валидации — можно useState. Например, поле «email для подписки на блог» с одной кнопкой. Как только полей больше трёх, или появляются вложенные поля, или нужны массивы — переходите на RHF, иначе быстро утонете в ручной синхронизации.
Zod или Valibot?
Valibot новее и легче (модульный — берёте только то, что используете), но экосистема пока меньше. У Zod больше плагинов, документации и интеграций (resolvers для RHF, tRPC, drizzle-zod). Если нужен tree-shaking и размер бандла критичен — Valibot. В остальных случаях — Zod.
Стоит ли использовать Server Actions для всех форм?
Для большинства — да, удобно и типизировано. Но не везде: если форма должна работать без JavaScript на клиенте (например, в email-клиенте при предзаполнении из ссылки), нужен честный API-роут с обычным POST. Также для интеграции с внешними системами (CRM, биллинг) часто проще иметь отдельный API-эндпоинт.
Как валидировать асинхронно — например, проверить, что email не занят?
Через superRefine в Zod или через свой эффект на blur поля. RHF поддерживает asyncValidate в register: register("email", { validate: async (v) => (await checkEmail(v)) || "Email уже занят" }). Делайте debounce 300-500 мс, чтобы не дёргать сервер на каждое нажатие.
Как защититься от двойной отправки?
disabled={isSubmitting} на кнопке + idempotency-key на сервере. Клиент при первом submit генерирует UUID, шлёт в заголовке X-Idempotency-Key. Сервер хранит ключ 5-10 минут в Redis: если такой уже был, возвращает прошлый ответ. Это спасает от дублей при двойном клике, медленной сети, повторной отправке после reload.
Стоит ли показывать ошибку сразу при вводе или только после blur?
После blur (mode: "onBlur" в RHF) — стандарт. Сразу при вводе — раздражает: пользователь начал писать email, и ему уже мигает «некорректный». Исключение — поля с подсказкой по силе пароля или счётчиком символов, там показ в реальном времени уместен.
Что делать, если у пользователя плохой интернет и форма зависает?
Таймаут на запрос (10-15 секунд через AbortController), чёткое сообщение «Не удалось отправить, проверьте интернет» с кнопкой «Повторить», локальное сохранение введённого в localStorage до подтверждения отправки. Хороший UX-паттерн — показать сообщение «Отправляем... Можно закрыть страницу, отправится в фоне» если у вас Service Worker с background sync.