Формы и валидация: контролируемые поля, схемы, UX ошибок
В предыдущей статье мы разобрались, как данные приходят в приложение. Форма — обратное направление: данные уходят от пользователя к вам. И это то место, где интерфейс превращается в деньги: регистрация, оформление заказа, заявка на кредит, добавление карты. Всё остальное приложение может быть красивым и быстрым — если форма отваливается на четвёртом шаге, пользы ноль.
Формы кажутся простой темой ровно до момента, когда вы пишете вторую. Потом выясняется, что нужно: сохранять черновик, валидировать одно поле относительно другого, спрашивать сервер «этот email свободен?», не ругаться на человека, который ещё печатает, показать всё сразу при сабмите, поставить фокус на первую ошибку, объявить её скринридеру, не отправить заявку дважды при двойном клике, разобрать ответ 422 и разложить его по полям, а ещё — не тормозить, когда полей сорок.
Эта статья про механику: где физически лежит значение, кто его валидирует, когда показывается ошибка и что при этом происходит с производительностью. Код — на React и TypeScript, потому что так пишут в проде, но разделы про платформу работают где угодно. Про сам язык — типы, дженерики, вывод типов из схем — есть отдельный трек TypeScript.
1. Почему формы — самая дорогая часть интерфейса
Форма — единственный компонент, в котором одновременно живут четыре независимые оси сложности:
- Состояние. У каждого поля есть значение, и оно меняется чаще, чем что-либо ещё в приложении, — десятки раз в секунду при быстром наборе.
- Правила. Часть проверок локальна («email непустой»), часть связывает поля («пароль совпадает с подтверждением»), часть требует сети («логин занят»), часть — условна («ИНН обязателен, только если тип клиента — юрлицо»).
- Время. Проверка может быть мгновенной, отложенной (после ухода из поля), асинхронной с гонками, а сабмит — долгим и падающим.
- Коммуникация. Ошибку нужно не просто найти, а объяснить: где, что не так и что делать. Причём и глазами, и голосом скринридера.
Каждая ось по отдельности проста. Их произведение — нет. Отсюда правило, к которому мы будем возвращаться: не смешивайте оси. Хранение значения, вычисление правил, решение «показывать ли ошибку сейчас» и рендер сообщения — четыре разные задачи, и хороший код формы держит их раздельно.
Начнём с оси времени, потому что она — источник большинства споров в код-ревью. Вот жизненный цикл одного поля:
Обратите внимание на два состояния, которые в коде обычно забывают: Pristine (человек ещё не трогал поле — ругаться не за что) и ServerInvalid (ошибка пришла с сервера и её нужно вернуть в конкретное поле, а не в общий тост). Половина плохого UX форм — это склеенные Pristine и Invalid: страница открылась, и всё уже красное.
2. Где живёт значение: три источника истины
У значения поля есть ровно три возможных места жительства, и путаница между ними — корень большинства багов.
| Где лежит | Кто владеет | Как читать | Когда это правильно |
|---|---|---|---|
DOM-узел input.value |
браузер | ref.current.value, FormData |
значение нужно только при сабмите |
| Состояние компонента | React | переменная состояния | UI зависит от значения на каждом символе |
| Сервер | бэкенд | ответ запроса | значение уже сохранено, форма его редактирует |
Контролируемое поле
Значение хранит React, DOM — только отображает. value и onChange замыкают цикл:
function ControlledEmail() {
const [email, setEmail] = useState('');
return (
<label>
Email
<input
type="email"
value={email} // источник истины — состояние
onChange={(e) => setEmail(e.target.value)} // без этого поле «залипнет»
/>
{/* UI мгновенно реагирует на каждый символ */}
<span>{email.length} символов</span>
</label>
);
}
Плюс: значение всегда доступно, любой производный UI (счётчик символов, живой предпросмотр, зависимое поле) пишется тривиально. Минус: каждый символ — это рендер компонента, который держит состояние, и всего его поддерева.
Неконтролируемое поле
Значение хранит DOM, React про него не знает до сабмита:
function UncontrolledForm() {
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
// Object.fromEntries удобен, но теряет множественные значения:
// для чекбокс-групп и multiple select нужен getAll
const payload = {
email: String(data.get('email') ?? ''),
tags: data.getAll('tags').map(String),
};
console.log(payload);
}
return (
<form onSubmit={handleSubmit}>
{/* name обязателен: без него поле не попадёт в FormData */}
<input type="email" name="email" defaultValue="" />
<button type="submit">Отправить</button>
</form>
);
}
Плюс: ноль рендеров при вводе, работает нативная валидация браузера, форма остаётся формой (об этом ниже). Минус: чтобы что-то показать по значению, придётся его специально прочитать.
Как выбирать
Правило простое: контролируйте то, от чего зависит UI прямо сейчас; остальное оставьте DOM.
- Поиск с живой фильтрацией, слайдер с числом рядом, поле с автодополнением — контролируемое, иначе никак.
- Форма регистрации, чекаут, настройки профиля — неконтролируемая, а показ ошибок делается точечной подпиской.
- Смешивать в одной форме можно и нужно: библиотеки форм именно так и устроены.
Отдельная ловушка React: если начать с value={undefined}, а потом передать строку, компонент переключится с неконтролируемого на контролируемый и вы получите предупреждение в консоли. Лечится тем, что все значения по умолчанию задаются явно (defaultValues в React Hook Form, '' вместо undefined).
Ещё одна: «залипшее поле». <input value={email} /> без onChange React делает read-only — символы не появляются вообще. Если поле должно быть только для чтения, так и напишите: readOnly (значение уйдёт в FormData) или disabled (не уйдёт — частая причина «на сервер не пришло поле»).
3. Платформа умеет больше, чем принято думать
Прежде чем тянуть библиотеку, стоит знать, что уже есть в браузере. Мы разбирали семантику полей в статье про семантический HTML; здесь — валидационная часть.
<form id="signup" novalidate>
<label for="email">Рабочий email</label>
<input id="email" name="email" type="email" required
autocomplete="email" inputmode="email" aria-describedby="email-error">
<p id="email-error" class="error"></p>
<label for="pwd">Пароль</label>
<input id="pwd" name="pwd" type="password" required minlength="12"
autocomplete="new-password">
<button type="submit">Создать аккаунт</button>
</form>
Что работает само:
required,type,pattern,min/max,step,minlength/maxlength— Constraint Validation API: браузер считает валидность и выставляет:valid,:invalid,:required.:user-invalidи:user-validсрабатывают только после взаимодействия. Это ровно разделениеPristine/Touched, ради которого раньше писали код:input:user-invalid { border-color: crimson }не покрасит нетронутое поле. Поддерживается всеми актуальными движками.novalidateубирает нативные пузыри-подсказки, но не отключает вычисление валидности:input.validityиcheckValidity()работают. Стандартный приём — правила берём у браузера, отрисовку делаем сами.
const form = document.querySelector('#signup');
const email = document.querySelector('#email');
const emailError = document.querySelector('#email-error');
// Свои тексты вместо «Please fill out this field»
function describe(input) {
const v = input.validity; // объект ValidityState
if (v.valueMissing) return 'Укажите рабочий email — на него придёт ссылка.';
if (v.typeMismatch) return 'Похоже, в адресе опечатка: нужен вид name@company.ru.';
if (v.tooShort) return `Минимум ${input.minLength} символов, сейчас ${input.value.length}.`;
return input.validationMessage; // запасной вариант от браузера
}
email.addEventListener('blur', () => {
const ok = email.checkValidity();
email.setAttribute('aria-invalid', String(!ok));
emailError.textContent = ok ? '' : describe(email);
});
form.addEventListener('submit', (e) => {
if (!form.checkValidity()) {
e.preventDefault();
form.querySelector(':invalid')?.focus(); // фокус на первое невалидное поле
}
});
Ещё два инструмента: input.setCustomValidity('текст') помечает поле невалидным для браузера (сбрасывать обязательно пустой строкой, иначе оно останется невалидным навсегда), а form.requestSubmit() — программный сабмит, который проходит валидацию и вызывает обработчик submit, в отличие от form.submit(), тихо игнорирующего и то, и другое.
CSS поля: место под ошибку, состояния, цели нажатия
Стили формы — это не украшение, а часть механики: они отвечают за отсутствие сдвига раскладки и за то, различима ли ошибка без цвета.
.field {
display: grid;
gap: 4px;
margin-block-end: 16px;
}
.field :is(input, select, textarea) {
font: inherit; /* иначе Safari на iOS зумит при фокусе поля <16px */
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: inherit;
}
/* Ошибка только после взаимодействия: чистое поле не красим */
.field :is(input, select, textarea):user-invalid,
.field [aria-invalid='true'] {
border-color: var(--danger);
border-width: 2px; /* толщина, а не только цвет — для дальтоников */
}
/* Место под сообщение зарезервировано ВСЕГДА: появление текста не двигает раскладку (CLS) */
.field .error {
min-block-size: 1.25rem;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--danger);
}
.field .error:not(:empty)::before { content: '⚠ '; }
/* Видимый фокус обязателен: клавиатурная навигация по форме — базовый сценарий */
:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
/* WCAG 2.2 «Target Size (Minimum)»: цель нажатия не меньше 24×24 CSS-пикселей */
.field :is([type='checkbox'], [type='radio']) { inline-size: 20px; block-size: 20px; }
.field label { min-block-size: 24px; display: flex; align-items: center; gap: 8px; }
/* Текстовое поле, растущее по содержимому, без JS (Chrome 123+, прогрессивно) */
.field textarea { field-sizing: content; min-block-size: 4lh; }
@media (prefers-reduced-motion: no-preference) {
.field :is(input, textarea) { transition: border-color 120ms ease; }
}
Про :has() есть соблазн написать .field:has(:user-invalid) label { color: red } — можно, но не увлекайтесь: подсветка подписи красным ухудшает контраст и мало что добавляет к сообщению под полем.
Группы полей: fieldset и legend
Радиокнопки и чекбокс-группы — то место, где «поле» не равно одному <input>. Общий вопрос («Способ доставки») должен быть <legend> внутри <fieldset>, иначе скринридер прочитает только вариант, но не вопрос.
<fieldset aria-describedby="delivery-error">
<legend>Способ доставки</legend>
<label><input type="radio" name="delivery" value="courier" required> Курьер, завтра</label>
<label><input type="radio" name="delivery" value="pickup"> Самовывоз из пункта выдачи</label>
<!-- описание группы; aria-invalid ставим на сами радио, поддержка на fieldset неровная -->
<p id="delivery-error" class="error">Выберите способ доставки</p>
</fieldset>
Тонкости, которые экономят время: required достаточно поставить на одну радиокнопку группы — браузер считает группу по атрибуту name; у чекбокс-группы такого механизма нет, «выберите хотя бы одно» придётся проверять самим; фокус при ошибке ставится на первую кнопку группы, а не на <fieldset>.
Прогрессивное улучшение и React 19
Если форма — это настоящий <form> с action и method, она работает без JavaScript: при медленной загрузке бандла, при ошибке скрипта, в экзотическом браузере. React 19 сделал этот путь первоклассным через useActionState и useFormStatus:
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
type State = { error?: string; fieldErrors?: Record<string, string> };
async function subscribeAction(_prev: State, formData: FormData): Promise<State> {
const email = String(formData.get('email') ?? '').trim();
if (!email.includes('@')) return { fieldErrors: { email: 'Нужен адрес вида name@company.ru' } };
const res = await fetch('/api/subscribe', { method: 'POST', body: formData });
if (res.status === 422) return { fieldErrors: (await res.json()).errors };
if (!res.ok) return { error: 'Сервис недоступен, попробуйте через минуту.' };
return {};
}
function SubmitButton() {
// useFormStatus читает статус БЛИЖАЙШЕЙ формы-предка,
// поэтому кнопка обязана быть отдельным компонентом внутри <form>
const { pending } = useFormStatus();
return <button type="submit" disabled={pending}>{pending ? 'Отправляем…' : 'Подписаться'}</button>;
}
export function SubscribeForm() {
const [state, formAction] = useActionState(subscribeAction, {});
return (
<form action={formAction}>
<input name="email" type="email" required aria-invalid={!!state.fieldErrors?.email} />
{state.fieldErrors?.email && <p role="alert">{state.fieldErrors.email}</p>}
{state.error && <p role="alert">{state.error}</p>}
<SubmitButton />
</form>
);
}
Ключевая деталь: action получает FormData, а не событие. Значит те же данные уйдут и обычным POST, если JS не успел загрузиться, — при условии, что серверный обработчик умеет их принять. Это дешёвая страховка, которую стоит закладывать сразу. Оборотная сторона: React очищает поля формы после успешного выполнения action, поэтому при ошибке значения нужно вернуть самим — через defaultValue из возвращённого состояния.
4. Валидация как схема, а не набор if
Разрозненные проверки — if (!email) …; if (password.length < 12) … — плохи не потому, что некрасивы, а потому что они не переиспользуются. Их нельзя отправить на сервер, из них нельзя вывести тип, их нельзя протестировать отдельно от компонента.
Прежде чем писать код, полезно разложить правила по видам: у каждого своя стоимость и свой момент запуска.
валидации)) Поле обязательность формат и длина диапазон значений где: HTML или схема Межполевые пароль и подтверждение дата от и дата до сумма и остаток где: refine на объекте Условные ИНН только для юрлица адрес только для доставки где: discriminatedUnion Асинхронные email свободен промокод действует где: debounce и abort Серверные права доступа лимиты и антифрод уникальность в базе где: только бэкенд
Первые три вида схема выражает декларативно. Возьмём Zod:
import { z } from 'zod';
export const AccountType = z.enum(['individual', 'company']);
const base = {
email: z
.string()
.trim()
.min(1, 'Укажите email — на него придёт подтверждение')
.email('Похоже на опечатку: нужен вид name@company.ru'),
password: z
.string()
.min(12, 'Минимум 12 символов — так пароль сложнее подобрать')
.regex(/[0-9]/, 'Добавьте хотя бы одну цифру'),
passwordConfirm: z.string(),
agreedToTerms: z.literal(true, {
errorMap: () => ({ message: 'Без согласия с условиями мы не можем создать аккаунт' }),
}),
};
// Условные поля — не через if в компоненте, а через размеченное объединение
export const SignupSchema = z
.discriminatedUnion('accountType', [
z.object({ accountType: z.literal('individual'), ...base }),
z.object({
accountType: z.literal('company'),
companyName: z.string().min(2, 'Укажите название организации'),
inn: z.string().regex(/^\d{10}$/, 'ИНН юрлица — 10 цифр'),
...base,
}),
])
// Межполевое правило вешается на объект целиком, а не на поле
.refine((v) => v.password === v.passwordConfirm, {
path: ['passwordConfirm'], // без path ошибка «повиснет» на форме
message: 'Пароли не совпадают',
});
// Тип формы выводится из схемы — не пишем его руками, не рассинхронизируем
export type SignupInput = z.infer<typeof SignupSchema>;
Три вещи, ради которых это делается:
- Тип выводится из правил.
z.inferдаётSignupInputавтоматически. Добавили поле в схему — TypeScript тут же покажет, где его забыли обработать. - Схема исполняется где угодно. Тот же модуль импортируется в браузере и в обработчике API.
- Ошибки структурированы.
safeParseвозвращает не строку, а дерево:
const result = SignupSchema.safeParse(payload);
if (!result.success) {
// { formErrors: string[], fieldErrors: { email: string[], inn: string[] } }
const { fieldErrors } = result.error.flatten();
return Response.json({ errors: fieldErrors }, { status: 422 });
}
// result.data типизирован и уже прошёл trim/coerce
await createAccount(result.data);
Полезные приёмы, которые экономят часы:
// 1) Приведение типов: <input type="number"> и FormData всё равно дают строку
const Filters = z.object({
page: z.coerce.number().int().min(1).default(1),
onlyActive: z.coerce.boolean().default(false), // осторожно: '' → false, 'false' → true
});
// 2) Нормализация на входе: пользователь пишет телефон как хочет
const Phone = z
.string()
.transform((s) => s.replace(/\D/g, ''))
.refine((d) => d.length === 11, 'Номер должен содержать 11 цифр');
// 3) Несколько ошибок за один проход — superRefine вместо цепочки refine
const Password = z.string().superRefine((val, ctx) => {
if (val.length < 12) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Короче 12 символов' });
if (!/[A-ZА-Я]/.test(val)) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Нужна заглавная буква' });
});
// 4) Схема шага мастера собирается из общей — не копипастой
const Step1 = SignupSchema.innerType().options[0].pick({ email: true, password: true });
// 5) Разбор FormData одной строкой (для нативных форм и React 19 Actions)
const parsed = Filters.safeParse(Object.fromEntries(formData));
Про версии. В Zod 4 строковые форматы вынесены наверх: вместо
z.string().email()рекомендуютz.email(), а вместоmessage/required_error— единый параметрerror. Код выше работает в v3 и в основном в v4; при переезде сверяйтесь с руководством по миграции. Практический совет: держите схемы в одном пакете, тогда миграция — это правки в одном месте.
Альтернативы Zod, если она вам не подходит: Valibot (модульный, дерево-тряска доводит рантайм до ~1–2 КБ вместо ~14 КБ у Zod v3 — важно для лендингов), Yup (старше, слабее выводит типы), ArkType (быстрее на больших объектах), TypeBox (если нужен JSON Schema для OpenAPI). С 2024 года у большинства есть общий интерфейс Standard Schema, поэтому резолверы форм принимают любую из них — и это тот редкий случай, когда выбор библиотеки действительно обратим.
Тексты сообщений и локализация
Сообщение в схеме — часть продукта, а не отладочный вывод. Если приложение многоязычное, не зашивайте русский текст в схему: возвращайте ключ, а перевод подставляйте на слое рендера.
// Схема отдаёт ключ и параметры, а не готовую строку
const password = z.string().min(12, { message: 'errors.password.tooShort' });
// В компоненте
<p className="error">{error && t(error.message, { min: 12 })}</p>
Плюс к этому: тот же ключ приедет с сервера в ответе 422, и вам не придётся поддерживать два набора формулировок.
Кому можно верить
Главная мысль картинки формулируется одной строкой: клиентская валидация — это UX, серверная — это корректность. Любую клиентскую проверку обходит curl за десять секунд. Поэтому:
- клиент проверяет, чтобы человек не ждал сеть ради опечатки;
- сервер проверяет ту же схему, потому что это единственная проверка, которой можно верить;
- база держит инварианты (
UNIQUE,CHECK,NOT NULL), потому что между проверкой «email свободен» и вставкой строки проходит время, и в этот зазор помещается гонка.
Уникальность email — канонический пример: асинхронная проверка на клиенте даёт хороший UX, но настоящую гарантию даёт только UNIQUE-индекс, и обработчик обязан уметь красиво поймать нарушение и вернуть его как ошибку поля.
Отдельно про безопасность: валидация формата — не защита от инъекций. Экранирование при выводе, параметризованные запросы, проверка прав и лимитов — всё это живёт на сервере независимо от того, что прошло схему. Схема отвечает на вопрос «данные правильной формы?», а не «этому пользователю можно?».
5. React Hook Form на практике
React Hook Form (RHF) — де-факто стандарт для React. Его архитектурная ставка: поля неконтролируемые, подписки точечные, ререндер — только там, где действительно поменялось.
import { useForm, useWatch, Controller, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { SignupSchema, type SignupInput } from '@app/contracts/signup';
export function SignupForm({ onDone }: { onDone: () => void }) {
const {
register, handleSubmit, control, setError, setFocus, reset,
formState: { errors, isSubmitting, submitCount },
} = useForm<SignupInput>({
resolver: zodResolver(SignupSchema),
mode: 'onTouched', // первая проверка — по уходу из поля
reValidateMode: 'onChange', // после первой ошибки — на каждый символ
defaultValues: {
accountType: 'individual',
email: '', password: '', passwordConfirm: '', agreedToTerms: false,
},
});
const accountType = useWatch({ control, name: 'accountType' });
const onSubmit = handleSubmit(async (data) => {
try {
const res = await fetch('/api/signup', {
method: 'POST',
headers: { 'content-type': 'application/json', 'idempotency-key': crypto.randomUUID() },
body: JSON.stringify(data),
});
if (res.status === 422) {
// Раскладываем серверные ошибки по полям — это и есть ServerInvalid
const { errors } = (await res.json()) as { errors: Record<string, string[]> };
for (const [field, messages] of Object.entries(errors)) {
setError(field as keyof SignupInput, { type: 'server', message: messages[0] });
}
setFocus(Object.keys(errors)[0] as keyof SignupInput);
return;
}
if (!res.ok) throw new Error(String(res.status));
reset(); // очищаем значения И флаги isDirty/touchedFields
onDone();
} catch {
// root-ошибка не привязана к полю: сеть, 500, таймаут
setError('root.serverError', { message: 'Не получилось отправить. Проверьте связь и попробуйте снова.' });
}
});
return (
<form onSubmit={onSubmit} noValidate>
{submitCount > 0 && <ErrorSummary errors={errors} setFocus={setFocus} />}
<Field label="Email" error={errors.email}>
<input type="email" autoComplete="email" {...register('email')} />
</Field>
<Field label="Пароль" error={errors.password}>
<input type="password" autoComplete="new-password" {...register('password')} />
</Field>
{accountType === 'company' && (
<Field label="ИНН" error={'inn' in errors ? errors.inn : undefined}>
<input inputMode="numeric" {...register('inn')} />
</Field>
)}
{/* Компонент из UI-кита не умеет ref/onChange нативного input — оборачиваем */}
<Controller
control={control}
name="accountType"
render={({ field }) => (
<SegmentedControl value={field.value} onChange={field.onChange} onBlur={field.onBlur} />
)}
/>
{errors.root?.serverError && <p role="alert">{errors.root.serverError.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Создаём аккаунт…' : 'Создать аккаунт'}
</button>
</form>
);
}
Обёртка Field — то место, где один раз навсегда решаются вопросы доступности:
import { cloneElement, useId, type ReactElement } from 'react';
import type { FieldError } from 'react-hook-form';
export function Field({ label, hint, error, children }: {
label: string; hint?: string; error?: FieldError; children: ReactElement;
}) {
const id = useId();
const errId = `${id}-err`;
const hintId = `${id}-hint`;
return (
<div className="field">
<label htmlFor={id}>{label}</label>
{hint && <p id={hintId} className="hint">{hint}</p>}
{cloneElement(children, {
id,
// порядок важен: скринридер читает подсказку, потом ошибку
'aria-describedby': [hint && hintId, errId].filter(Boolean).join(' '),
'aria-invalid': !!error,
})}
{/* контейнер существует всегда: место зарезервировано (нет сдвига раскладки),
живой регион успевает зарегистрироваться до появления текста */}
<p id={errId} className="error" aria-live="polite">{error?.message ?? ''}</p>
</div>
);
}
Разбор режимов проверки — это то, что нужно осознанно выбрать один раз на проект:
mode |
Первая проверка | Кому подходит |
|---|---|---|
onSubmit (по умолчанию) |
только при сабмите | короткие формы из 2–3 полей |
onBlur |
при уходе из поля | формы с медленной валидацией |
onTouched |
при первом уходе, дальше — по reValidateMode |
разумный дефолт для большинства форм |
onChange |
на каждый символ | почти никогда: ругается, пока человек печатает |
all |
blur и change | отладка |
Грабли, на которые наступают все:
formState— это Proxy. ЧитаяisValid, вы подписываетесь на него и получаете ререндер на каждое изменение валидности. ЕслиisValidнужен только для кнопки — вынесите кнопку в отдельный компонент сuseFormState({ control }).watch('field')перерисовывает всю форму,useWatch({ control, name })— только вызвавший компонент. По умолчанию используйте второй.defaultValuesобязательны. Без них поля стартуют как неконтролируемые иreset()не вернёт их в исходное состояние. Если значения приходят с сервера — либоdefaultValues: () => fetchUser()(RHF ждёт промис), либоvalues={user}для синхронизации при обновлении данных.reset(values)меняет и «базу» дляisDirty. После успешного сохранения формы редактирования вызывайтеreset(savedData), иначе форма останется «грязной».registerвозвращаетonChange/onBlur/ref/name. Если вы дописываете свойonChangeпосле спреда, вы затрёте библиотечный:{...register('x')} onChange={...}— типичный «поле не валидируется». Правильно —const f = register('x')и вызватьf.onChange(e)внутри своего.useFieldArrayтребует ключfield.id, а не индекс массива: при удалении середины списка индексы съезжают и React переиспользует не тот DOM-узел — с чужим введённым текстом. Это ровно та проблема сkey, которую мы разбирали в React: реконсиляция.
const { fields, append, remove } = useFieldArray({ control, name: 'participants' });
{fields.map((field, i) => (
<div key={field.id}> {/* НЕ key={i} */}
<input {...register(`participants.${i}.name` as const)} />
<button type="button" onClick={() => remove(i)}>Убрать</button>
</div>
))}
<button type="button" onClick={() => append({ name: '' })}>Добавить участника</button>
Асинхронная проверка без гонок
Проверка «email свободен» — это сетевой запрос на каждое изменение поля. Три обязательных элемента: задержка, отмена и защита от устаревшего ответа.
export function useAvailability(delayMs = 400) {
const ctrl = useRef<AbortController | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
return useCallback((email: string) => new Promise<boolean>((resolve) => {
clearTimeout(timer.current);
ctrl.current?.abort(); // отменяем предыдущий запрос
const ac = new AbortController();
ctrl.current = ac;
timer.current = setTimeout(async () => {
try {
const r = await fetch(`/api/emails/${encodeURIComponent(email)}`, { signal: ac.signal });
resolve(r.status === 404); // 404 = свободен
} catch {
resolve(true); // сеть упала — не блокируем человека,
} // финальное слово всё равно за сервером
}, delayMs);
}), [delayMs]);
}
Подключается через register('email', { validate: async (v) => (await check(v)) || 'Такой email уже зарегистрирован' }). Обратите внимание на строку с catch: сетевая ошибка асинхронной проверки не должна мешать отправить форму. Иначе человек с плохим Wi-Fi не сможет зарегистрироваться никогда.
Второй нюанс — обратная связь на время ожидания. Пока запрос летит, поле не валидно и не невалидно: покажите спиннер рядом с полем и не давайте сабмиту «проскочить» — в RHF за это отвечает formState.isValidating.
6. Когда показывать ошибку: reward early, punish late
Это самый недооценённый раздел. Формула из исследования Wealthfront звучит так: хвалить рано, ругать поздно. Успех подтверждаем сразу, как только он достижим; об ошибке сообщаем, только когда человек закончил вводить.
(награда за исправление)"] D -- нет --> F["Оставить старый текст,
не мигать новым"] B -- нет --> G{"Правило может стать
истинным по мере ввода?"} G -- "да: длина, совпадение,
формат карты" --> H["Показать положительный
индикатор сразу"] G -- нет --> I["Ждать blur"] I --> J{"Ушёл из поля?"} J -- нет --> K["Молчать"] J -- да --> L{"Валидно?"} L -- да --> M["Тихая галочка или ничего"] L -- нет --> N["Показать ошибку под полем
+ aria-invalid"] N --> O["Сабмит: собрать все ошибки,
сводка сверху, фокус на первую"] style E fill:#6f9e6a,fill-opacity:0.2 style N fill:#b06a6a,fill-opacity:0.2 style K fill:#5a5a5a,fill-opacity:0.15
Практические выводы:
- Пока поле «чистое» — молчим. Никаких красных рамок на свежеоткрытой форме.
- Первая проверка — на
blur. Человек сам сказал «я закончил». - После первой ошибки — на каждый символ. Как только правило выполнилось, ошибка исчезает мгновенно. Это и есть
mode: 'onTouched'+reValidateMode: 'onChange'. - Ошибку не показываем во время исправления. Если человек стёр всё и печатает заново, текст ошибки не должен меняться на каждый символ — только исчезнуть, когда всё стало хорошо.
- Исключение для «догоняющих» правил. Индикатор надёжности пароля, «карта Visa распознана», «осталось 3 символа» — показываются сразу, потому что это подсказки, а не претензии.
- Отдельный случай —
type="password". Требования показывайте списком до ввода и отмечайте галочками по мере выполнения, а не выдавайте текстом ошибки после. - Не блокируйте кнопку «Отправить» до полной валидности. Отключённая кнопка не объясняет, что не так, и не фокусируется с клавиатуры. Пусть жмут — и получают внятную сводку.
7. UX ошибок: текст, фокус, доступность
Сообщение об ошибке отвечает на три вопроса. Если хотя бы на один не отвечает — оно бесполезно.
- Что произошло? — «Не удалось сохранить адрес»
- Почему? — «Индекс не соответствует городу»
- Что делать? — «Проверьте индекс: для Казани он начинается с 420»
| Плохо | Почему плохо | Хорошо |
|---|---|---|
| «Ошибка» | ни одного из трёх ответов | «Не получилось отправить: сервис недоступен. Попробуйте через минуту» |
| «Неверный формат» | нет образца | «Дата нужна в виде 31.12.2025» |
| «Поле обязательно» | не объясняет зачем | «Укажите телефон — курьер позвонит перед доставкой» |
| «Invalid input» | чужой язык | «В номере карты 16 цифр, вы ввели 15» |
| «Вы ввели неправильно» | обвиняет | «Такой email уже зарегистрирован. [Войти]» |
Расположение: сообщение — под полем, до 80 символов, рядом с тем, что чинят. Иконка + цвет + текст, а не только цвет (иначе дальтоник не увидит разницы). Не полагайтесь на placeholder вместо <label>: плейсхолдер исчезает при вводе, и человек забывает, что от него хотели.
Сводка ошибок и фокус
При сабмите невалидной формы нужно сделать три вещи: собрать все ошибки в блок сверху, поставить туда фокус и дать ссылки на поля. Это паттерн GOV.UK, проверенный на десятках миллионов пользователей.
export function ErrorSummary<T extends Record<string, unknown>>({
errors, setFocus,
}: { errors: FieldErrors<T>; setFocus: (name: any) => void }) {
const ref = useRef<HTMLDivElement>(null);
const list = Object.entries(errors).filter(([k]) => k !== 'root');
// Фокус на сводку: скринридер прочитает её целиком, зрячий увидит, куда смотреть
useEffect(() => { if (list.length) ref.current?.focus(); }, [errors]);
if (!list.length) return null;
return (
<div ref={ref} tabIndex={-1} role="alert" aria-labelledby="es-title" className="error-summary">
<h2 id="es-title">Не получилось отправить. Проверьте поля: {list.length}</h2>
<ul>
{list.map(([name, err]) => (
<li key={name}>
{/* ссылка, а не просто текст: работает с клавиатуры и в режиме чтения ссылок */}
<a href={`#${name}`} onClick={(e) => { e.preventDefault(); setFocus(name); }}>
{(err as { message?: string })?.message ?? 'Проверьте поле'}
</a>
</li>
))}
</ul>
</div>
);
}
Обязательный минимум по доступности (подробнее — в гайде по доступности):
- каждое поле связано с
<label for>или обёрнуто в<label>; группы —<fieldset>+<legend>; - ошибка связана с полем через
aria-describedby="field-error", а поле помеченоaria-invalid="true"; - контейнер ошибки существует в DOM до появления текста, иначе живой регион не сработает; текст вставляется внутрь;
role="alert"(илиaria-live="assertive") — только для сводки, не для каждого поля, иначе скринридер будет тараторить;- при сабмите фокус переезжает на сводку — программно,
tabIndex={-1}на контейнере; - обязательность помечается словом, а не только звёздочкой:
<label>Телефон <span class="req">(обязательно)</span></label>; - в SPA полезно менять
document.titleнаОшибка: Регистрация — Сервис: так ошибка видна в заголовке вкладки и объявляется при смене экрана.
Ошибка в сводке и ошибка под полем должны совпадать текстом. Разные формулировки в двух местах — частая находка на аудитах.
8. Сабмит: сеть, гонки и двойные отправки
но если запрос всё-таки ушёл дважды —
Idempotency-Key спасает от второго заказа Note over F,S: Уход со страницы во время отправки:
beforeunload только при isDirty, иначе это раздражает
Что из этого обязательно в проде:
- Блокировка кнопки на время отправки —
disabled={isSubmitting}с текстом «Отправляем…». Отключать нужно именно кнопку, а не форму: если отключить всё, фокус потеряется и скринридер замолчит. - Ключ идемпотентности. Клиент генерирует
crypto.randomUUID()один раз на попытку сабмита (а не на каждый ретрай) и шлёт в заголовке. Сервер запоминает результат и на повтор отдаёт тот же ответ. Это единственная надёжная защита от двойного заказа при плохой сети. - Разделение 4xx и 5xx.
422— ошибки полей, их раскладываем.409— конфликт состояния, обычно нужна ссылка на выход («войти», «обновить страницу»).429— «слишком часто», покажите, сколько ждать.5xxи таймаут — общая ошибка с кнопкой «Повторить», значения формы не стираем ни при каких обстоятельствах. - Контракт ошибок один на весь бэкенд. Формат
{ errors: { <имя поля>: string[] } }(или RFC 9457 Problem Details с расширением) должен быть общим — иначе каждая форма будет разбирать ответ по-своему. - Предупреждение об уходе — только если форма
isDirtyи не сабмитится:
useEffect(() => {
if (!isDirty || isSubmitting) return;
const onLeave = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = ''; };
window.addEventListener('beforeunload', onLeave);
return () => window.removeEventListener('beforeunload', onLeave);
}, [isDirty, isSubmitting]);
Оптимистичные обновления для форм применимы редко: показывать «сохранено» до ответа сервера можно там, где откат безболезненный (лайк, переключатель настройки), и нельзя там, где на кону деньги. В React 19 для этого есть useOptimistic, механику отката разбирали в статье про работу с данными.
9. Сложные поля: маски, деньги, файлы, черновики
Атрибут autocomplete — самая дешёвая оптимизация конверсии. Браузер и менеджер паролей подставят значения одним тапом, что критично на мобильном.
<input autocomplete="given-name"> <input autocomplete="family-name">
<input autocomplete="email"> <input autocomplete="tel" inputmode="tel">
<input autocomplete="street-address"> <input autocomplete="postal-code" inputmode="numeric">
<input autocomplete="cc-number" inputmode="numeric">
<input autocomplete="one-time-code" inputmode="numeric"> <!-- код из SMS подставится сам -->
<input autocomplete="new-password"> <!-- менеджер предложит сгенерировать -->
<input autocomplete="current-password"> <!-- подставит сохранённый -->
inputmode определяет клавиатуру на телефоне: numeric, decimal, tel, email, search. Разница с type="number" существенна: последний даёт спиннеры, ломается на ведущих нулях и путает разделитель в части локалей. Для номеров карт, ИНН и кодов — type="text" inputmode="numeric".
Маски. Форматирование на каждый символ ломает каретку: пользователь правит середину номера, вы переписываете value, каретка прыгает в конец. Варианты по возрастанию сложности:
- Не форматировать во время ввода, форматировать на
blur. Дёшево и почти всегда достаточно. - Взять готовую библиотеку (
imask,react-number-format) — они умеют восстанавливать позицию каретки. - Писать самому — только если у вас очень специфичный формат; заложите время на баги с кареткой, вставкой из буфера и удалением через Backspace.
Отдельно про ввод иероглифов и других языков с промежуточным набором: во время композиции input уже приходит, но текст ещё не финальный. Если вы что-то переписываете в onChange, вы порвёте набор:
const composing = useRef(false);
<input
onCompositionStart={() => { composing.current = true; }}
onCompositionEnd={(e) => { composing.current = false; format(e.currentTarget.value); }}
onChange={(e) => { if (!composing.current) format(e.currentTarget.value); }}
/>
Деньги. Храните в минорных единицах целым числом (копейки, центы) — тип number с плавающей точкой на суммах даёт классические 0.1 + 0.2. Отображайте через Intl.NumberFormat, парсите с учётом локали (в русской локали разделитель — запятая, а разряды разделяются неразрывным пробелом, который нужно вычищать перед парсингом).
const fmt = new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB' });
fmt.format(129900 / 100); // «1 299,00 ₽»
function parseAmount(input: string): number | null {
// \s в JS покрывает и неразрывный, и узкий неразрывный пробел
const cleaned = input.replace(/\s/g, '').replace(',', '.');
const n = Number(cleaned);
return Number.isFinite(n) ? Math.round(n * 100) : null; // храним копейки
}
Файлы. Проверяйте на клиенте размер и type, но помните: расширение и MIME от браузера — это подсказка, а не факт. Реальная проверка сигнатуры файла — на сервере. Обязательно: accept, ограничение размера с понятным текстом («файл 18 МБ, максимум 10 МБ»), превью для изображений через URL.createObjectURL с обязательным revokeObjectURL, прогресс загрузки (через XMLHttpRequest.upload.onprogress или fetch со стримом) — без прогресса человек нажмёт «Отправить» ещё раз.
Автосохранение вместо кнопки. В настройках и админках кнопка «Сохранить» часто лишняя: сохраняйте по blur поля или с задержкой 800–1000 мс после последнего изменения. Три обязательных элемента: видимый статус («Сохранено в 14:03» / «Сохраняем…» / «Не сохранено — нет связи»), объявление статуса через aria-live="polite" и очередь, гарантирующая порядок запросов (иначе ответ на предыдущее значение перетрёт следующее).
Многошаговые формы и черновики. Состояние одно на всю форму, схема на шаг собирается через .pick(). Черновик кладётся в localStorage с версией ключа:
const DRAFT_KEY = 'checkout-draft:v3'; // версия в ключе: сменили схему — старые черновики отвалились сами
// Сохраняем не чаще раза в секунду и никогда — секреты
useEffect(() => {
const sub = watch((values) => {
const { password, cvv, ...safe } = values as Record<string, unknown>;
localStorage.setItem(DRAFT_KEY, JSON.stringify({ at: Date.now(), values: safe }));
});
return () => sub.unsubscribe();
}, [watch]);
Три правила черновиков: не хранить пароли, коды и CVV; восстанавливать с подтверждением («Продолжить заполнение?»), а не молча; удалять после успешного сабмита и по истечении срока (например, суток). И ещё: восстановленный черновик прогоняйте через safeParse — схема с прошлого релиза могла измениться.
10. Почему форма тормозит и как это измерить
Формы — главный поставщик плохого INP (Interaction to Next Paint), метрики отзывчивости, которая с марта 2024 заменила FID в Core Web Vitals. Порог «хорошо» — 200 мс на 75-м перцентиле; «плохо» — больше 500 мс. Ввод текста считается взаимодействием, поэтому каждая тормозящая клавиша попадает в метрику.
Откуда берутся миллисекунды, по убыванию частоты:
- Большая контролируемая форма. Одно состояние в корне — весь рендер поддерева на символ. Это картинка из раздела 2.
- Валидация всей схемы на каждый ввод.
mode: 'onChange'с тяжёлымdiscriminatedUnionи десяткомrefine— это заметная работа, умноженная на скорость печати. - Дорогие поля. Select с тремя тысячами городов без виртуализации, редактор с подсветкой, живой предпросмотр Markdown.
- Layout thrashing. Чтение
getBoundingClientRectв обработчике ввода (например, для позиционирования подсказки) заставляет браузер пересчитывать раскладку синхронно. Механику разбирали в статье про работу браузера. - Синхронная аналитика. Отправка события на каждый
keyup.
Измерять надо, а не догадываться. Три инструмента:
// 1) Сырые данные: длительность каждого взаимодействия и что именно тормозило
new PerformanceObserver((list) => {
for (const e of list.getEntries() as PerformanceEventTiming[]) {
const total = e.duration; // от ввода до следующего кадра
const inputDelay = e.processingStart - e.startTime; // ждали занятый поток
const handler = e.processingEnd - e.processingStart; // работали в обработчике
if (total > 200) {
console.warn('Медленное взаимодействие', {
type: e.name, target: (e.target as Element)?.id, total, inputDelay, handler,
});
}
}
}).observe({ type: 'event', durationThreshold: 16, buffered: true });
// 2) Полевые данные: web-vitals с атрибуцией — покажет конкретный селектор виновника
import { onINP } from 'web-vitals/attribution';
onINP(({ value, attribution }) => {
navigator.sendBeacon('/rum', JSON.stringify({
metric: 'INP',
value,
target: attribution.interactionTarget, // например, "#checkout input[name=promo]"
phase: attribution.longestScriptType,
}));
}, { reportAllChanges: false });
- React DevTools → Profiler. Включите «Highlight updates when components render», напечатайте букву в поле. Если мигает вся форма — вы нашли проблему без единой строки кода. Дальше запись профиля покажет, сколько миллисекунд стоит один символ и какой компонент их съел. В обычном DevTools тот же эффект даёт вкладка Performance с включённым CPU throttling ×4 — на десктопе без замедления вы проблему просто не увидите.
Что делать с найденным:
| Симптом | Лечение |
|---|---|
| Мигает вся форма на символ | перейти на неконтролируемые поля (RHF) или опустить состояние в лист |
| Валидация 5+ мс на символ | mode: 'onTouched', тяжёлые правила — только на blur и сабмит |
| Подсказка/автокомплит тормозит | useDeferredValue для списка, виртуализация, отмена запросов |
inputDelay большой, handler маленький |
поток занят чем-то другим: разбейте длинные задачи, уберите работу из useEffect |
| Прыгает раскладка при появлении ошибки | зарезервировать место под сообщение (min-block-size) — это ещё и CLS |
Последний пункт важен вдвойне: появление текста ошибки сдвигает всё, что ниже, и портит CLS. Резервируйте высоту строки сообщения заранее — мы это сделали в CSS из раздела 3. Подробный разбор бюджетов и метрик — в статье про производительность фронтенда.
11. Тестирование форм
Форму тестируют так, как её видит пользователь: находят поля по подписи, печатают, жмут кнопку, читают сообщение. Селекторы по классам здесь особенно вредны — они переживут поломку доступности, а пользователь нет.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SignupForm } from './SignupForm';
test('показывает ошибку после ухода из поля и убирает её после исправления', async () => {
const user = userEvent.setup();
render(<SignupForm onDone={() => {}} />);
const email = screen.getByLabelText(/email/i); // ищем по подписи = проверяем связь label/input
await user.type(email, 'не-почта');
await user.tab(); // blur
const error = await screen.findByText(/опечатка/i);
expect(email).toHaveAttribute('aria-invalid', 'true');
expect(email).toHaveAccessibleDescription(/опечатка/i); // проверяем aria-describedby, а не вёрстку
await user.clear(email);
await user.type(email, 'me@company.ru');
expect(error).not.toBeInTheDocument(); // reValidateMode: onChange
});
test('серверная ошибка 422 попадает в конкретное поле', async () => {
server.use(http.post('/api/signup', () =>
HttpResponse.json({ errors: { email: ['Такой email уже зарегистрирован'] } }, { status: 422 })));
const user = userEvent.setup();
render(<SignupForm onDone={() => {}} />);
await user.type(screen.getByLabelText(/email/i), 'taken@company.ru');
await user.type(screen.getByLabelText(/пароль/i), 'Very-Long-Pass-1');
await user.click(screen.getByRole('button', { name: /создать/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/уже зарегистрирован/i);
expect(screen.getByLabelText(/email/i)).toHaveFocus();
});
Схему тестируют отдельно от компонента и параметризованной таблицей — это самые дешёвые и самые полезные тесты формы:
describe.each([
['пустой email', { email: '' }, 'email'],
['email без собаки', { email: 'user.company.ru' }, 'email'],
['короткий пароль', { password: 'abc' }, 'password'],
['пароли не совпали', { passwordConfirm: 'other' }, 'passwordConfirm'],
])('%s', (_name, patch, field) => {
it(`даёт ошибку в поле ${field}`, () => {
const res = SignupSchema.safeParse({ ...validInput, ...patch });
expect(res.success).toBe(false);
expect(res.error!.flatten().fieldErrors).toHaveProperty(field);
});
});
Обязательный набор сценариев для любой серьёзной формы: успешный сабмит; каждое правило валидации (таблицей, как выше); ошибка 422 с маппингом в поле; ошибка 5xx с сохранением введённых значений; двойной клик по кнопке отправляет один запрос; навигация с клавиатуры доходит до кнопки; фокус после сабмита оказывается там, где надо. Инструменты и границы уровней — в статье про тестирование фронтенда и в треке Тестирование.
12. Чем это писать: честное сравнение
Без фанатизма, по делу:
- Ничего не брать. Форма из двух-трёх полей без межполевых правил:
<form>+FormData+safeParseпри сабмите. Это правда лучший выбор, и он не устаревает. - React Hook Form. Зрелый, ~9 КБ gzip, минимум ререндеров, отличная интеграция со схемами. Слабое место — типизация глубоких путей (
items.3.price) в больших формах и то, чтоformStateкак Proxy иногда ведёт себя неочевидно. Дефолт для React в 2026-м. - TanStack Form. Моложе, но типобезопаснее по путям, не привязан к React (есть Vue/Solid/Svelte-адаптеры), валидация на уровне поля и формы устроена стройнее. Разумный выбор для нового проекта, где формы — ядро продукта.
- Formik. Историческое наследие. Контролируемый по умолчанию, отсюда проблемы с производительностью на больших формах. Новый код на нём начинать не стоит, но переписывать работающий — тоже не всегда оправдано.
- Vue: vee-validate, Svelte: Superforms, Angular: реактивные формы встроены в фреймворк и, пожалуй, лучшая штатная реализация форм среди мейнстрима — типизированные
FormGroup/FormControlиз коробки. Solid и Svelte 5 с сигналами вообще не сталкиваются с проблемой «весь рендер на символ»: обновление точечное по построению. Подробное сравнение экосистем — в статье про сравнение фреймворков.
Общее для всех: схема валидации не должна зависеть от библиотеки форм. Держите её отдельным модулем — тогда смена библиотеки будет стоить день, а не месяц.
13. Типичные ошибки
- Красная форма при открытии. Не различили
PristineиTouched. - Валидация только на клиенте. Пять минут работы
curl— и в базе мусор. placeholderвместо<label>. Подпись исчезает ровно тогда, когда нужна.- Очистка формы после ошибки сервера. Человек заполнял пятнадцать полей и потерял всё.
- Ошибка в тосте вместо поля. Тост исчезает через три секунды, поле остаётся невалидным и непонятно каким.
- Отсутствие фокуса на первой ошибке. На длинной форме ошибка может быть за пределами экрана, и человек видит просто «ничего не произошло».
key={index}в динамическом списке полей. Удалили второй элемент — введённый текст «переехал».- Асинхронная проверка без отмены. Ответ на «pet» приходит после ответа на «peter» и перетирает результат.
- Отключение всей формы на время отправки. Фокус теряется, скринридер молчит.
- Сообщения об ошибках в схеме на английском. Схема — часть продукта, тексты пишутся с той же тщательностью, что и кнопки.
type="number"для номера карты и ИНН. Ведущие нули, спиннеры, странности локалей.- Черновик с паролем в
localStorage. Любой скрипт на странице его прочитает. disabledвместоreadOnlyу предзаполненного поля. Значение не попадёт ни вFormData, ни на сервер.- Свой
onChangeпосле{...register()}. Спред затирается, поле молча перестаёт валидироваться.
Мини-итог
- У значения поля три возможных дома: DOM, состояние компонента, сервер. Осознанно выберите дом для каждого поля — иначе баги будут именно на стыках.
- Контролируемые поля нужны там, где UI зависит от значения на каждом символе. В остальных случаях DOM справляется сам, и это на порядок дешевле по рендерам.
- Платформа даёт
FormData, Constraint Validation API,:user-invalid,requestSubmit,autocomplete,fieldset. Библиотека форм — надстройка над этим, а не замена. - Валидация — это схема в отдельном модуле, из которой выводится тип и которая исполняется в браузере и на сервере. Клиент — ради UX, сервер — ради корректности, база — ради гонок.
- Момент показа ошибки важнее её текста: молчим до
blur, после первой ошибки перепроверяем на каждый символ, успех подтверждаем сразу. - Хорошее сообщение отвечает на «что / почему / что делать», лежит под полем, связано через
aria-describedby, а при сабмите дублируется в сводке, куда переезжает фокус. - Формы — главный источник плохого INP. Смотрите Profiler глазами, меряйте
PerformanceObserverиweb-vitals, держите бюджет 200 мс на взаимодействие.
Источники
- MDN. Client-side form validation и Constraint Validation API
- HTML Standard. The form element and constraint validation
- React.
useActionState,useFormStatus - React Hook Form — документация, TanStack Form
- Zod, Valibot, Standard Schema
- GOV.UK Design System. Error message, Error summary
- W3C. WCAG 2.2 — Understanding Error Identification
- Mihael Konjević. Inline validation in forms: designing the experience
- Baymard Institute. Inline form validation research
- Adam Silver. Form Design Patterns. Smashing Magazine, 2018
- Luke Wroblewski. Web Form Design: Filling in the Blanks. Rosenfeld Media, 2008
- web.dev. Interaction to Next Paint (INP) и Optimize INP
- Testing Library. user-event
Что дальше
Форма отправлена, данные ушли — но мы ни разу не обсудили, где вообще исполняется весь этот код: в браузере, на сервере или в момент сборки. От этого зависит и время первой отрисовки, и то, будет ли форма работать без JavaScript. Дальше — Роутинг и стратегии рендеринга: SPA, SSR, SSG, ISR, гидратация.