Правила проверки удобнее всего описать одной схемой Zod и использовать её дважды: в форме через React Hook Form и zodResolver и на сервере через safeParse — проверка в браузере экономит время пользователя, а защищает данные только серверная. Ошибку показывайте после ухода из поля (mode: "onTouched"), связывайте с полем через aria-invalid и aria-describedby, а кнопку на время отправки блокируйте по isSubmitting.
После материала вы сможете
- выбрать между управляемым (
value+onChange) и неуправляемым (defaultValue+FormData) полем и объяснить цену каждого - описать данные формы схемой Zod и проверить ей и форму, и тело запроса на сервере
- собрать форму на React Hook Form, которая показывает ошибку после ухода из поля и блокирует повторную отправку
- связать ошибку с полем через
aria-invalidиaria-describedbyи проверить это тестом Testing Library
Проверено на
React 19.3React Hook Form 7.90Zod 4.6@hookform/resolvers 5.9TypeScript 6.0Vite 8.3Vitest 5.0 + Testing Library 16.3
Сверено с документацией: 11 октября 2026 г.
Содержание
Управляемые и неуправляемые поля
Значение поля формы может жить в двух местах: в состоянии React или в самом DOM-элементе. Поле с value и onChange называется управляемым (controlled): React на каждом рендере принудительно ставит в него значение из состояния, а onChange это состояние обновляет. Поле без value, в лучшем случае с defaultValue, — неуправляемое (uncontrolled): начальное значение задаёт JSX, дальше значение хранит браузер, а читают его при отправке через FormData или ref.
Документация React фиксирует несколько жёстких правил. Если передать value без onChange, в поле невозможно печатать, а в консоли появится предупреждение. Значение управляемого поля не должно быть undefined или null — начинайте с пустой строки. Поле не может за время жизни переключиться из неуправляемого в управляемое. И главное для производительности: управляемое поле меняет состояние на каждое нажатие клавиши, поэтому при большом дереве компонентов ввод начинает тормозить — состояние стоит опускать в маленький компонент рядом с полем.
Где значение
- Управляемое
- Состояние React (
useState) - Неуправляемое
- DOM-элемент, читается при отправке
Пропсы
- Управляемое
value+onChange(checkedдля чекбокса)- Неуправляемое
defaultValue/defaultChecked, обязательноname
Ререндер
- Управляемое
- На каждое нажатие клавиши
- Неуправляемое
- Только когда меняется что-то ещё
Когда брать
- Управляемое
- Интерфейс зависит от значения прямо сейчас: счётчик символов, зависимое поле, поиск по мере ввода
- Неуправляемое
- Значение нужно только при отправке — большинство полей заявки
import { useState } from "react";
const MAX = 200;
/* controlled: the value lives in state because the UI depends on it */
export function CommentField() {
const [comment, setComment] = useState("");
const left = MAX - comment.length;
return (
<div>
<label htmlFor="comment">Comment</label>
<textarea
id="comment"
name="comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
maxLength={MAX}
/>
<p>{left} characters left</p>
</div>
);
}Нативная валидация HTML и её пределы
Браузер умеет проверять поля без единой строки JavaScript. Атрибуты required, minlength, pattern, типы email и url задают ограничения (constraints), а результат проверки лежит в объекте ValidityState (input.validity). Пока хоть одно поле не проходит проверку, браузер не отправит форму: событие submit не наступит, и React-обработчик onSubmit не вызовется, а у первого неверного поля появится системная подсказка.
import type { FormEvent } from "react";
type Props = { onSend: (data: Record<string, FormDataEntryValue>) => void };
/* uncontrolled fields + built-in browser validation */
export function NativeLeadForm({ onSend }: Props) {
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); // the browser calls onSubmit only if every field is valid
onSend(Object.fromEntries(new FormData(e.currentTarget)));
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="native-name">Name</label>
<input id="native-name" name="name" required minLength={2} autoComplete="name" />
<label htmlFor="native-phone">Phone</label>
<input
id="native-phone"
name="phone"
type="tel"
required
pattern="[+0-9 \(\)\-]{10,18}"
autoComplete="tel"
/>
<button type="submit">Send</button>
</form>
);
}FormDataДля простой формы этого может хватить. Но у нативной проверки есть пределы, из-за которых в приложениях её обычно дополняют своей:
- Текст и вид системных сообщений зависят от браузера, и стилизовать их нельзя — MDN прямо пишет об этом на странице
aria-invalid. - Подсказку браузер показывает у одного поля за раз, а не список всех ошибок.
- Часть ограничений, например
minlength, проверяется только для введённого пользователем значения, а не для заданного из JavaScript. - Нормализовать данные (привести телефон «8 700…» к «+7700…») атрибутами нельзя.
- Проверку в браузере легко обойти, поэтому MDN подчёркивает: она не отменяет проверку на сервере. А правила на сервере придётся описать ещё раз — если у вас нет общей схемы.
Схема на Zod: одна правда для клиента и сервера
Схема (schema) — это описание формы данных в коде: какие поля, какого типа, с какими ограничениями и какими текстами ошибок. Zod — библиотека для TypeScript, которая по схеме и проверяет значение, и выводит из неё статический тип. Метод parse возвращает проверенную копию данных или бросает ZodError, safeParse вместо исключения возвращает объект { success, data | error }. Текст ошибки в Zod 4 задаётся параметром error.
import * as z from "zod";
export const SERVICES = ["website", "web-app", "crm"] as const;
/* "8 (700) 123-45-67", "+7 700 123 45 67", "7001234567" → "+77001234567" */
export function normalizePhone(raw: string): string {
const digits = raw.replace(/\D/g, "");
if (digits.length === 10) return `+7${digits}`;
if (digits.length === 11 && /^[78]/.test(digits)) return `+7${digits.slice(1)}`;
return raw.trim();
}
export const leadSchema = z.object({
name: z
.string()
.trim()
.min(2, { error: "Enter your name" })
.max(80, { error: "Name is too long" }),
phone: z
.string()
.transform(normalizePhone)
.pipe(z.string().regex(/^\+7\d{10}$/, { error: "Enter a phone like +7 700 123 45 67" })),
service: z.string().pipe(z.enum(SERVICES, { error: "Choose a service" })),
consent: z.boolean().refine((value) => value, { error: "Consent is required to send the form" }),
});
/* what the form holds (input) and what you get after parsing (output) */
export type LeadInput = z.input<typeof leadSchema>;
export type Lead = z.output<typeof leadSchema>;Три решения в этой схеме стоит разобрать. Телефон принимается в любом привычном виде и через transform приводится к одному формату, а уже потом pipe проверяет результат регуляркой — это ровно тот принцип «нормализовать формат — задача кода», который описан в уроке «Формы без боли». Услуга в форме сначала пустая строка, поэтому вход описан как z.string(), а результат сужается до z.enum. Из-за transform и pipe тип входа и тип результата различаются, и Zod отдаёт оба: z.input — то, что лежит в форме, z.output — то, что получит обработчик отправки.
Та же схема на сервере закрывает дыру, которую не закрывает браузер: запрос можно отправить в обход формы. z.flattenError превращает ошибку в плоский объект «поле → сообщения», который удобно вернуть в ответе API:
import * as z from "zod";
import { leadSchema } from "./lead-schema.ts";
/* the same schema on the server: the browser can be bypassed, the server cannot */
export function parseLead(body: unknown) {
const result = leadSchema.safeParse(body);
if (!result.success) {
return { ok: false as const, fieldErrors: z.flattenError(result.error).fieldErrors };
}
return { ok: true as const, lead: result.data };
}import { parseLead } from "./server-validate.ts";
console.log(parseLead({ name: " Aigerim ", phone: "8 (700) 123-45-67", service: "crm", consent: true }));
console.log(parseLead({ name: "A", phone: "123", service: "", consent: false }));$ node src/server-check.ts
{
ok: true,
lead: {
name: 'Aigerim',
phone: '+77001234567',
service: 'crm',
consent: true
}
}
{
ok: false,
fieldErrors: {
name: [ 'Enter your name' ],
phone: [ 'Enter a phone like +7 700 123 45 67' ],
service: [ 'Choose a service' ],
consent: [ 'Consent is required to send the form' ]
}
}React Hook Form: регистрация полей и момент ошибки
React Hook Form (RHF) строится на неуправляемых полях: функция register("name") возвращает name, ref, onChange и onBlur, которые разворачиваются в <input>. Значения хранятся в DOM и во внутреннем хранилище библиотеки, а компонент перерисовывается только когда меняется то, на что он подписан, например список ошибок. Схему подключают через resolver из пакета @hookform/resolvers. Документация RHF предупреждает: вместе с resolver встроенные правила вроде register("x", { required: true }) не работают — все правила живут в схеме.
npm install react-hook-form zod @hookform/resolversМомент проверки задаёт опция mode, а повторную проверку после первой отправки — reValidateMode (по умолчанию onChange):
onSubmit (по умолчанию)
- Когда проверяет
- При отправке, затем поля с ошибками — при изменении
- Что видит пользователь
- Ошибки только после нажатия кнопки
onBlur
- Когда проверяет
- При уходе из поля
- Что видит пользователь
- Ошибка появляется после ухода, но и исчезает только после нового ухода
onChange
- Когда проверяет
- На каждое изменение
- Что видит пользователь
- Ругань с первого символа; документация RHF предупреждает о заметной цене для производительности
onTouched
- Когда проверяет
- Первый раз — при уходе из поля, дальше — на каждое изменение
- Что видит пользователь
- Пока печатает — тишина; после ошибки она исчезает сразу, как только поле исправлено
all
- Когда проверяет
- И при уходе, и при изменении
- Что видит пользователь
- Как
onChange, плюс проверка при уходе
Для формы заявки берём onTouched: он совпадает с рекомендацией из урока «Формы без боли» — не ругать, пока человек печатает, проверять после ухода из поля и убирать ошибку сразу после исправления.
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { leadSchema, type Lead, type LeadInput } from "./lead-schema.ts";
type Props = { submitLead: (lead: Lead) => Promise<void> };
const describedBy = (...ids: (string | false)[]) => ids.filter(Boolean).join(" ") || undefined;
export function LeadForm({ submitLead }: Props) {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<LeadInput, unknown, Lead>({
resolver: zodResolver(leadSchema),
mode: "onTouched", // first check on blur, then on every change
defaultValues: { name: "", phone: "", service: "", consent: false },
});
async function onSubmit(lead: Lead) {
try {
await submitLead(lead);
} catch {
setError("root.serverError", {
message: "Could not send the request. Try again or message us on WhatsApp.",
});
}
}
if (isSubmitSuccessful) {
return <p role="status">Thank you! We will call you back within a working day.</p>;
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="lead-name">Name</label>
<input
id="lead-name"
autoComplete="name"
aria-invalid={errors.name ? true : undefined}
aria-describedby={describedBy(!!errors.name && "lead-name-error")}
{...register("name")}
/>
{errors.name && <p id="lead-name-error">{errors.name.message}</p>}
<label htmlFor="lead-phone">Phone</label>
<input
id="lead-phone"
type="tel"
autoComplete="tel"
aria-invalid={errors.phone ? true : undefined}
aria-describedby={describedBy("lead-phone-hint", !!errors.phone && "lead-phone-error")}
{...register("phone")}
/>
<p id="lead-phone-hint">Any format: 8 700…, +7 700…</p>
{errors.phone && <p id="lead-phone-error">{errors.phone.message}</p>}
<label htmlFor="lead-service">Service</label>
<select
id="lead-service"
aria-invalid={errors.service ? true : undefined}
aria-describedby={describedBy(!!errors.service && "lead-service-error")}
{...register("service")}
>
<option value="">Choose…</option>
<option value="website">Website</option>
<option value="web-app">Web application</option>
<option value="crm">CRM</option>
</select>
{errors.service && <p id="lead-service-error">{errors.service.message}</p>}
<input
id="lead-consent"
type="checkbox"
aria-invalid={errors.consent ? true : undefined}
aria-describedby={describedBy(!!errors.consent && "lead-consent-error")}
{...register("consent")}
/>
<label htmlFor="lead-consent">I agree to the processing of my data</label>
{errors.consent && <p id="lead-consent-error">{errors.consent.message}</p>}
{errors.root?.serverError && <p role="alert">{errors.root.serverError.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Sending…" : "Get a quote"}
</button>
</form>
);
}useForm<LeadInput, unknown, Lead>— третий параметр типа описывает данные после схемы, поэтомуonSubmitполучает уже нормализованный телефон.defaultValuesзаданы для всех полей: документация RHF советует не использоватьundefinedв значениях по умолчанию — это конфликтует с управляемыми полями.noValidateотключает системные подсказки браузера: ошибки показывает сама форма, единообразно и с нужным текстом.
Отправка: pending, ошибка сервера, успех
Проверка
handleSubmitпрогоняет данные через схему. Есть ошибки —onSubmitне вызывается, а фокус по умолчанию переходит на первое неверное поле (shouldFocusError: true).Ожидание
Пока промис из
onSubmitне завершился,formState.isSubmittingравенtrue: кнопка заблокирована и говорит «Отправляем…». Повторный клик ничего не отправит — это проверяет тест с двойным кликом.Ошибка сервера
Сетевой сбой или ответ не 2xx ловим в
catchи записываем черезsetError("root.serverError"). Такая ошибка не привязана к полю и, по документации RHF, не сохраняется между отправками — повторная попытка начинается с чистого листа.Успех
Если обработчик завершился без ошибок,
isSubmitSuccessfulстановитсяtrue, и вместо формы появляется сообщение сrole="status".
Сама отправка вынесена в проп submitLead. Компонент не знает, куда уходят данные, — в тестах вместо сети подставляется заглушка, а в приложении — обычный fetch, который превращает ответ не 2xx в исключение:
import { LeadForm } from "./LeadForm.tsx";
import type { Lead } from "./lead-schema.ts";
async function submitLead(lead: Lead) {
const res = await fetch("/api/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(lead),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
}
export default function App() {
return <LeadForm submitLead={submitLead} />;
}/api/leads — пример; на сервере тело запроса проверяет parseLead из предыдущего разделаДоступность ошибок: aria-invalid и aria-describedby
Зрячий пользователь видит красную рамку и текст под полем. Скринридеру этого мало: ему нужно знать, что поле неверно, и какой текст к нему относится. Для этого в форме три механизма, и все они уже есть в LeadForm.tsx:
Состояние ошибки
- Как в коде
aria-invalid="true"только после проверки- Зачем
- Скринридер объявляет поле как неверное. MDN: не ставить на пустое обязательное поле до попытки отправки
Текст ошибки и подсказка
- Как в коде
aria-describedbyс id подсказки и id ошибки- Зачем
- Связь поля с сообщением, как в учебнике W3C WAI; текст читается при фокусе на поле
Фокус после отправки
- Как в коде
shouldFocusErrorв RHF (включён по умолчанию)- Зачем
- W3C WAI советует переводить фокус на первое поле с ошибкой
Ошибка без поля
- Как в коде
role="alert"у сообщения сервера- Зачем
- Объявляется сразу, хотя фокус остаётся на кнопке
Успех
- Как в коде
role="status"- Зачем
- Вежливое объявление, что заявка ушла
Хелпер describedBy собирает aria-describedby только из существующих id: ссылка на элемент ошибки появляется вместе с самим элементом. У телефона в описании сразу два id — постоянная подсказка о формате и ошибка, когда она есть. MDN описывает и отдельный атрибут aria-errormessage; в примере использован aria-describedby, как в учебнике W3C WAI.
Проверка: тесты и частые ошибки
Тесты на Testing Library работают так, как работает пользователь: находят поле по подписи, печатают, уходят клавишей Tab, нажимают кнопку. getByLabelText найдёт поле, только если <label> связан с ним, а toHaveAccessibleDescription проверяет, что aria-describedby действительно указывает на текст ошибки. Поэтому тест заодно проверяет доступность.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { LeadForm } from "./LeadForm.tsx";
async function fillValid(user: ReturnType<typeof userEvent.setup>) {
await user.type(screen.getByLabelText("Name"), "Aigerim");
await user.type(screen.getByLabelText("Phone"), "8 (700) 123-45-67");
await user.selectOptions(screen.getByLabelText("Service"), "crm");
await user.click(screen.getByLabelText(/I agree/));
}
describe("LeadForm", () => {
it("does not complain while typing, shows the error on blur", async () => {
const user = userEvent.setup();
render(<LeadForm submitLead={vi.fn()} />);
const name = screen.getByLabelText("Name");
await user.type(name, "A");
expect(screen.queryByText("Enter your name")).not.toBeInTheDocument();
await user.tab();
expect(await screen.findByText("Enter your name")).toBeInTheDocument();
expect(name).toHaveAttribute("aria-invalid", "true");
expect(name).toHaveAccessibleDescription("Enter your name");
await user.type(name, "igerim"); // after the first blur it re-validates on change
expect(screen.queryByText("Enter your name")).not.toBeInTheDocument();
expect(name).not.toHaveAttribute("aria-invalid");
});
it("on empty submit shows every error and focuses the first invalid field", async () => {
const user = userEvent.setup();
const submitLead = vi.fn();
render(<LeadForm submitLead={submitLead} />);
await user.click(screen.getByRole("button", { name: "Get a quote" }));
expect(await screen.findByText("Choose a service")).toBeInTheDocument();
expect(screen.getByText("Consent is required to send the form")).toBeInTheDocument();
expect(screen.getByLabelText("Name")).toHaveFocus();
expect(screen.getByLabelText("Phone")).toHaveAccessibleDescription(
"Any format: 8 700…, +7 700… Enter a phone like +7 700 123 45 67",
);
expect(submitLead).not.toHaveBeenCalled();
});
it("sends normalized data once and blocks the button while pending", async () => {
const user = userEvent.setup();
let finish!: () => void;
const submitLead = vi.fn(() => new Promise<void>((resolve) => (finish = resolve)));
render(<LeadForm submitLead={submitLead} />);
await fillValid(user);
const button = screen.getByRole("button", { name: "Get a quote" });
await user.dblClick(button);
expect(await screen.findByRole("button", { name: "Sending…" })).toBeDisabled();
expect(submitLead).toHaveBeenCalledTimes(1);
expect(submitLead).toHaveBeenCalledWith({
name: "Aigerim",
phone: "+77001234567",
service: "crm",
consent: true,
});
finish();
expect(await screen.findByRole("status")).toHaveTextContent("Thank you!");
});
it("shows a server error and lets the user retry", async () => {
const user = userEvent.setup();
const submitLead = vi.fn().mockRejectedValueOnce(new Error("HTTP 500")).mockResolvedValueOnce(undefined);
render(<LeadForm submitLead={submitLead} />);
await fillValid(user);
await user.click(screen.getByRole("button", { name: "Get a quote" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Could not send the request");
await user.click(screen.getByRole("button", { name: "Get a quote" }));
expect(await screen.findByRole("status")).toBeInTheDocument();
expect(submitLead).toHaveBeenCalledTimes(2);
});
});$ npx vitest run src/LeadForm.test.tsx --reporter=verbose
✓ src/LeadForm.test.tsx > LeadForm > does not complain while typing, shows the error on blur
✓ src/LeadForm.test.tsx > LeadForm > on empty submit shows every error and focuses the first invalid field
✓ src/LeadForm.test.tsx > LeadForm > sends normalized data once and blocks the button while pending
✓ src/LeadForm.test.tsx > LeadForm > shows a server error and lets the user retry
Test Files 1 passed (1)
Tests 4 passed (4)value без onChange
- Симптом
- В поле нельзя печатать, предупреждение в консоли
- Исправление
- Добавить
onChangeили перейти наdefaultValue
Начальное значение undefined
- Симптом
- Предупреждение о переходе из неуправляемого в управляемое
- Исправление
- Пустая строка в
useState("")иdefaultValues
pattern без экранирования под флаг v
- Симптом
- Ограничение молча не работает
- Исправление
- Экранировать
(,),-и другие символы внутри класса
register("x", { required: true }) вместе с resolver
- Симптом
- Правило игнорируется
- Исправление
- Перенести правило в схему
mode: "onChange" для всей формы
- Симптом
- Ошибка на первом символе, лишние ререндеры
- Исправление
onTouchedилиonBlur
Проверка только в браузере
- Симптом
- Неверные данные приходят через прямой запрос
- Исправление
safeParseтой же схемы на сервере
Как проверено5 · пройденосверено 11 октября 2026 г.
- запущено, работает
src/CommentField.tsx
tsc -b + vite build + vitest (1 тест: счётчик после ввода), React 19.3.0, TypeScript 6.0.3, Vite 8.3.4, Vitest 5.0.3 + @testing-library/react 16.3.3, jsdom 30.1.2
- запущено, работает
src/NativeLeadForm.tsx
vitest (2 теста): отправка блокируется при пустых required (jsdom); pattern компилируется с флагом v. Первый вариант pattern с неэкранированными скобками тест поймал — исправлено. Системные сообщения реальных браузеров не проверялись (jsdom)
- запущено, работает
src/lead-schema.ts, src/server-validate.ts, src/server-check.ts
node --no-warnings src/server-check.ts (Node.js 24.18, type stripping) — вывод в статье реальный; vitest (1 тест) на отклонение данных в обход формы; Zod 4.6.6
- запущено, работает
src/LeadForm.tsx + src/App.tsx
tsc -b + vite build + vitest (4 теста, user-event 14.6.7): ошибка при blur и снятие при исправлении, aria-invalid и toHaveAccessibleDescription, фокус на первом поле, двойной клик → один вызов (контрольно: без disabled тест падает), root.serverError + повтор. React Hook Form 7.90.0, @hookform/resolvers 5.9.1. fetch в App.tsx не вызывался против реального API — только сборка
- запущено, работает
Упражнения (extend схемы; mode onSubmit; LeadRejectedError + setError по полю)
решение apply — vitest (1 тест: сообщение у поля, aria-invalid, фокус); extend — vitest; modify — смена mode действительно роняет первый тест
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.