open atlas
↑ К треку
React с нуля до senior RCT · 04 · 02

Состояние формы и валидация: values, errors, touched — и когда запускать проверку

Форма — три карты (values, errors, touched) плюс политика тайминга: валидируем на blur, перепроверяем на change после касания, отсекаем на submit. Одна zod-схема на клиент и сервер; ошибкам нужны aria-describedby и управление фокусом, иначе часть пользователей их не найдёт.

RCT Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Воронка регистрации теряла 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-ом в браузере и пропускает серверную валидацию, чтобы не дублировать правила. Какова реальная экспозиция?

Вспомните перед уходом
  1. 01
    Опишите гибридную политику тайминга валидации и что каждая чистая политика (всегда onChange, только onSubmit, только onBlur) делает не так.
  2. 02
    Почему паттерн общей zod-схемы строго лучше раздельной клиентской и серверной валидации, и какая асимметрия легитимно остаётся?
Итог

Продакшен-состояние формы — три карты с разными работами: values хранит ввод пользователя, errors выводится валидатором из values на каждом рендере (отдельно не хранится — и потому не рассинхронизируется), touched фиксирует, какие поля пользователь закончил, — это шлюз показа, отделяющий факт «значение невалидно» от решения «сказать пользователю сейчас». Политика тайминга, уважающая обе половины: поле показывает ошибку после первого blur; будучи тронутым и в ошибке, перепроверяется на каждый change, чтобы исправление гасило ошибку мгновенно; submit перегоняет полную схему со всеми полями touched, ловя нетронутые поля и межполевые правила, и переносит фокус на первый провал или сводку ошибок. Правила валидации живут один раз — в zod-схеме, общей для двух сред: браузерный парс — быстрая обратная связь, серверный — настоящий шлюз (клиентские проверки выполняются на машине атакующего и не доказывают ничего), а z.infer приваривает TypeScript-типы к тому же источнику истины; по-настоящему серверные проверки вроде уникальности email вливают свои асинхронные ошибки в ту же карту. Доступность — это проводка, а не стилизация: noValidate глушит нативные пузыри, aria-invalid на инпуте, aria-describedby на id элемента с ошибкой, чтобы сообщение объявлялось вместе с полем, и явное управление фокусом при провале submit — без этого красный текст невидим для всех, кто на него не смотрит.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.