Состояние формы и валидация: values, errors, touched — и когда запускать проверку
Форма — три карты (values, errors, touched) плюс политика тайминга: валидируем на blur, перепроверяем на change после касания, отсекаем на submit. Одна zod-схема на клиент и сервер; ошибкам нужны aria-describedby и управление фокусом, иначе часть пользователей их не найдёт.
Воронка регистрации теряла 31% на шаге email, и записи сессий объяснили это безжалостно. Форма валидировала по onChange с первого символа: пользователь набирал «j» — поле мгновенно краснело: Введите корректный email — и оставалось красным, крича на него, все одиннадцать символов до конца ввода. Кто-то останавливался перечитывать набранное; кто-то решал, что его адрес «не принимают», и уходил. Первый фикс команды — валидировать только на submit — нанёс противоположную травму: пользователи заполняли девять полей, жали «Отправить» и получали шесть ошибок разом, причём вьюпорт был проскроллен вниз и первое сломанное поле осталось за кадром. Худший репорт прислал незрячий пользователь со скринридером: submit «ничего не делал» — текст ошибки рендерился отдельным красным div-ом, который ни одна ассистивная технология не связывала ни с каким инпутом, а фокус оставался на кнопке. Три бага, одна корневая причина: команда считала валидацию булевым переключателем — вкл или выкл, — тогда как на деле это машина состояний над тремя картами и политикой тайминга.
Форма: values, errors, touched
Все библиотеки форм сходятся к одним и тем же трём параллельным картам, потому что те отвечают на три независимых вопроса. values — что пользователь ввёл (controlled-состояние из прошлого урока). errors — что сейчас не так с каждым полем; выводится валидатором из values. touched — какие поля пользователь закончил трогать; ставится, когда поле получает blur. Третью карту самодельные формы забывают — а именно она делает валидацию вежливой: ошибку стоит показывать только для тронутого поля. Валидация может выполняться сколь угодно часто; touched — это шлюз показа, отделяющий «это значение невалидно» (факт о данных) от «сказать об этом пользователю сейчас» (UX-решение).
const schema = z.object({
email: z.string().email("Enter a valid email"),
password: z.string().min(12, "At least 12 characters"),
});
function Signup() {
const [values, setValues] = useState({ email: "", password: "" });
const [touched, setTouched] = useState({});
const result = schema.safeParse(values);
const errors = result.success
? {}
: Object.fromEntries(
result.error.issues.map((i) => [i.path[0], i.message])
);
const showError = (field) => touched[field] && errors[field];
return (
<form noValidate onSubmit={/* gate on result.success, see below */ undefined}>
<input
value={values.email}
onChange={(e) => setValues({ ...values, email: e.target.value })}
onBlur={() => setTouched({ ...touched, email: true })}
aria-invalid={Boolean(showError("email"))}
aria-describedby={showError("email") ? "email-error" : undefined}
/>
{showError("email") ? (
<p id="email-error" role="alert">{errors.email}</p>
) : null}
</form>
);
}Заметьте: errors здесь — производное состояние, вычисляемое из values при рендере и никогда не хранимое отдельно. Хранение ошибок в собственном useState приглашает классический рассинхрон: значение поменялось, протухшая ошибка продолжает показываться.
Тайминг: три политики и кого каждая наказывает
onChange с первого символа наказывает людей за незаконченную мысль: каждое поле «невалидно», пока не дописано, и форма кричит во время набора. Только onSubmit наказывает за доверие: десять полей усилий — и стена ошибок, часто за пределами экрана. onBlur — компромисс, к которому раз за разом приходят полевые UX-исследования: уходя из поля, пользователь сигналит «я закончил», и вердикт в этот момент честен.
Продакшен-политика гибридная, её называют reward early, punish late — награждай рано, наказывай поздно: валидируем поле при первом blur; как только поле тронуто и содержит ошибку — перепроверяем на каждый change, чтобы ошибка исчезала в момент исправления (награда обязана быть мгновенной: заставлять снова делать blur, чтобы убрать уже исправленную ошибку, читается как поломка); и прогоняем полную схему на submit как финальный шлюз, потому что какие-то поля могли вообще не быть тронуты. Цифры с полей: замена агрессивной посимвольной валидации на blur-гибрид — одно из самых стабильно положительных изменений UX форм, которые команды измеряют, именно потому, что преждевременные ошибки читаются как отказ.
▸Почему это работает
Зачем вообще шлюз на submit, если каждое поле валидируется на blur? Две причины. Нетронутые поля: пользователь может протабать мимо обязательного поля, не набрав ничего — нет события change, возможно нет осмысленного blur, — и пополевая валидация не запускалась. И межполевые правила: «пароли должны совпадать» или «дата конца позже даты начала» не имеют единственного «своего» поля; submit — единственный момент, когда объект целиком судится честно. Обработчик submit перегоняет полную схему, помечает все поля touched (чтобы каждая ошибка стала видимой) и отказывается отправлять, если что-то не прошло.
Одна схема, две среды исполнения
Клиентская валидация — это UX-приём, а не безопасность: клиент — машина атакующего, и любой человек с DevTools отправит в ваш API что угодно. Значит, сервер обязан валидировать в любом случае — что исторически означало писать каждое правило дважды и смотреть, как они расползаются: клиент разрешает 100 символов, сервер обрезает на 80, и пользователь получает тост «сохранено» для молча искалеченных данных. Схемные библиотеки растворяют дублирование. Zod-схема — обычное JavaScript-значение: определите её один раз в общем модуле, вызывайте safeParse на вводе пользователя в браузере ради мгновенной обратной связи и safeParse на теле запроса на сервере как настоящий шлюз. Одни правила, одни сообщения, один источник истины — а TypeScript выводит тип формы из схемы (z.infer), так что и типы расползтись не могут. Остающаяся асимметрия легитимна: сервер может проверить то, что клиенту недоступно (уникальность email по базе), и такие ошибки возвращаются асинхронно и вливаются в ту же карту errors.
Ошибки, которые пользователь способен найти
Сообщение об ошибке, визуально прилегающее к инпуту, для скринридера — посторонний абзац где-то в документе. Это чинят три провода. aria-invalid на инпуте объявляет его состояние. aria-describedby, указывающий на id элемента с ошибкой, заставляет ассистивные технологии читать сообщение при фокусе на инпуте — связь явная, а не пространственная. И при провалившемся submit — переместить фокус: либо на первый невалидный инпут (пользователь приземляется туда, где работа), либо на сводку ошибок сверху со ссылками на каждое сломанное поле. Без управления фокусом неудачный submit — тишина: зрячий видит «где-то красное», пользователь скринридера не слышит вообще ничего. Атрибут noValidate на форме тоже важен: он отключает нативные браузерные пузыри, чтобы ваши доступные стилизованные ошибки были единственным голосом, а не вторым из двух несогласных.
Почему продакшен-форма держит отдельную карту touched, а не показывает просто всё, что лежит в errors?
Команда тщательно валидирует zod-ом в браузере и пропускает серверную валидацию, чтобы не дублировать правила. Какова реальная экспозиция?
- 01Опишите гибридную политику тайминга валидации и что каждая чистая политика (всегда onChange, только onSubmit, только onBlur) делает не так.
- 02Почему паттерн общей zod-схемы строго лучше раздельной клиентской и серверной валидации, и какая асимметрия легитимно остаётся?
Продакшен-состояние формы — три карты с разными работами: values хранит ввод пользователя, errors выводится валидатором из values на каждом рендере (отдельно не хранится — и потому не рассинхронизируется), touched фиксирует, какие поля пользователь закончил, — это шлюз показа, отделяющий факт «значение невалидно» от решения «сказать пользователю сейчас». Политика тайминга, уважающая обе половины: поле показывает ошибку после первого blur; будучи тронутым и в ошибке, перепроверяется на каждый change, чтобы исправление гасило ошибку мгновенно; submit перегоняет полную схему со всеми полями touched, ловя нетронутые поля и межполевые правила, и переносит фокус на первый провал или сводку ошибок. Правила валидации живут один раз — в zod-схеме, общей для двух сред: браузерный парс — быстрая обратная связь, серверный — настоящий шлюз (клиентские проверки выполняются на машине атакующего и не доказывают ничего), а z.infer приваривает TypeScript-типы к тому же источнику истины; по-настоящему серверные проверки вроде уникальности email вливают свои асинхронные ошибки в ту же карту. Доступность — это проводка, а не стилизация: noValidate глушит нативные пузыри, aria-invalid на инпуте, aria-describedby на id элемента с ошибкой, чтобы сообщение объявлялось вместе с полем, и явное управление фокусом при провале submit — без этого красный текст невидим для всех, кто на него не смотрит.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.