{ "index": 200, "slug": "editorial-2022-06-mechanism-form-errors", "title": "Ошибка формы должна относиться к тому вводу, который человек видит", "excerpt": "Как связать значение поля, попытку отправки и сообщение об ошибке, чтобы поздний ответ сервера не переписал исправленный ввод.", "contentHtml": "

Человек вводит неправильный email и нажимает «Отправить». Форма отправляет запрос. Пока сервер отвечает, человек исправляет адрес и нажимает кнопку снова. Затем рядом с новым адресом появляется сообщение «Адрес уже занят», хотя сервер проверял старое значение. Визуально форма показывает ошибку. По смыслу она лжёт.

\n

Цена ошибки выше, чем стоимость ещё одного текста под полем. Пользователь может исправить правильный адрес, повторить отправку несколько раз или решить, что сервис потерял данные. Поддержка получает неясный вопрос. Команда видит случайный дефект: он появляется только при определённом порядке ввода и ответов.

\n

Тезис статьи прост: сообщение об ошибке, поле, которое оно описывает, и попытка отправки должны принадлежать одному снимку данных. Нельзя применять ответ только потому, что он пришёл последним или называет то же поле. Надёжная форма хранит attemptId, версии значений и отдельный semantic payload. Старый ответ можно показать в журнале диагностики, но нельзя возвращать его в текущее поле.

\n

Как возникает рассинхронизация

\n

У формы есть как минимум два времени. Первое — время ввода: значение email меняется с каждым редактированием. Второе — время запроса: сервер получает снимок и отвечает позже. Эти часы не обязаны идти в одном порядке. Ответ первой попытки может прийти после ответа второй.

\n

Наивный обработчик часто выглядит так:

\n
function onServerResult(result) {\n  if (result.errors.email) {\n    state.emailError = result.errors.email;\n  }\n}\n\n// Любой поздний ответ может переписать состояние текущего поля.\n
\n

В этом коде нет связи между result и значением, которое сейчас лежит в поле. Имя email совпадает, но снимки могут различаться. Обработчик знает только содержание ответа, а не его право менять состояние.

\n

Вторая ошибка — складывать разные причины в одну строку form.error. Локальная проверка отвечает на вопрос «можно ли отправлять этот ввод». Серверная проверка отвечает на вопрос «принял ли сервер конкретный снимок». Ошибка сети отвечает на вопрос «доставлен ли результат». У этих сигналов разные владельцы и срок жизни.

\n

Минимальный контракт состояния

\n

При каждой правке поля увеличивайте его version. Перед отправкой сохраните неизменяемый снимок значений и версий. Одновременно выдайте попытке новый attemptId. Ответ имеет право изменить форму только тогда, когда совпали оба условия: попытка ещё активна, а версии ответа совпадают с текущими версиями проверенных полей.

\n
const form = {\n  values: { email: 'person@example.test' },\n  versions: { email: 1 },\n  activeAttempt: null,\n  nextAttemptId: 1,\n  errors: { local: null, server: null },\n};\n\nfunction changeEmail(next) {\n  form.values.email = next;\n  form.versions.email += 1;\n  form.errors.local = validateEmail(next);\n  form.errors.server = null;\n}\n\nfunction beginSubmit() {\n  if (form.errors.local || form.activeAttempt) return null;\n  const attempt = {\n    attemptId: form.nextAttemptId,\n    values: { ...form.values },\n    versions: { ...form.versions },\n  };\n  form.activeAttempt = attempt;\n  form.nextAttemptId += 1;\n  return attempt;\n}\n\nfunction receiveServerResult(result) {\n  const active = form.activeAttempt;\n  if (!active || result.attemptId !== active.attemptId) {\n    return { kind: 'unknown-attempt-ignored' };\n  }\n  if (\n    result.versions?.email !== active.versions.email\n    || result.versions?.email !== form.versions.email\n  ) {\n    form.activeAttempt = null;\n    return { kind: 'stale-response-ignored' };\n  }\n  form.activeAttempt = null;\n  form.errors.server = result.errors?.email ?? null;\n  return { kind: 'applied' };\n}
\n

Это учебный пример. Он не выполняет HTTP-запрос, не создаёт DOM, не измеряет задержку и не доказывает порядок сетевых пакетов. Поле nextAttemptId здесь — локальный монотонный счётчик: каждая новая отправка получает уникальный attemptId в пределах жизни формы. Это не серверный идентификатор и не ключ идемпотентности API. Пример проверяет только право ответа изменить локальное состояние и принадлежность ответа к снимку своей попытки.

\n

При новой правке серверная ошибка очищается сразу. Это не означает, что сервер уже принял новое значение. Это означает, что старое сообщение больше не относится к текущему вводу. После этого пользователь может отправить новый снимок. Если старый ответ придёт позднее, сравнение версий остановит его.

\n

Сообщение об ошибке состоит из нескольких связей

\n

Текст сам по себе не даёт полю семантической связи. Компонент должен знать, какое поле не прошло проверку, какой элемент содержит описание и какое действие ожидается от пользователя. Удобно собирать эти данные одним переходом состояния:

\n
const fieldError = {\n  field: 'email',\n  attemptId: 2,\n  version: 3,\n  messageId: 'email-server-error',\n  message: 'Этот адрес уже используется. Укажите другой адрес.',\n  invalid: true,\n};
\n

Из этого объекта можно построить HTML. Например:

\n
<label for="email">Email</label>\n<input id="email" name="email" type="email"\n  aria-invalid="true"\n  aria-errormessage="email-server-error">\n<p id="email-server-error">\n  Этот адрес уже используется. Укажите другой адрес.\n</p>
\n

Разметка — не замена state-контракту. Если после новой правки атрибут остался, а текст относится к старому ответу, интерфейс всё ещё сообщает неверную причину. WAI-ARIA требует использовать aria-errormessage вместе с aria-invalid; когда ошибка больше не относится к вводу, текст нужно скрыть от пользователей или удалить эту связь. И наоборот: корректный объект в unit-тесте не доказывает, что реальный DOM связывает элементы именно так или что конкретная вспомогательная технология озвучит их ожидаемым образом.

\n

Симптомы и действия

\n
Диагностика несвязанной ошибки формы
СимптомПричинаПроверкаДействие
Старое сообщение появляется после правкиОтвет сопоставляют только по имени поляСравнить версии снимка и текущего значенияПометить ответ stale и не менять ошибку
Вторая отправка создаётся поверх первойСостояние хранит только boolean loadingПроверить активный attempt до создания новогоЗаблокировать повтор или явно определить очередь
Ошибка есть, но поле не названоСерверный текст потерял field keyНайти источник сообщения и его идентификаторХранить field-specific record отдельно от общего статуса
Красный текст остался после исправленияОчистка зависит от submit, а не от editИзменить поле после ошибки и посмотреть stateСбрасывать связанную server error при новой версии
Один ответ применился дваждыОбработчик не закрывает active attemptПовторно передать тот же resultИгнорировать ответ без активной попытки
Форма очищается после отказаUI заменяет values ответом или пересоздаёт модельСравнить values до submit и после errorХранить снимок и возвращать пользователю введённые данные
\n

Иллюстрация механизма

\n
\"Связь
Схема показывает локальный контракт данных. Она не описывает реальную задержку сети и не является результатом проверки screen reader.
\n

На схеме важна граница stale response. Ответ не уничтожается и не считается ошибкой транспорта. Он просто теряет право менять пользовательский state. Это отрицательный путь, который должен быть виден в тесте. Без него happy path легко создаёт ложное ощущение готовности.

\n

Порядок внедрения и проверки

\n
  1. Зафиксируйте симптом. Воспроизведите один порядок: submit, edit, затем поздний ответ. Запишите значение поля, текст ошибки и состояние кнопки.
  2. Назовите владельцев. Отдельно определите, где живут values, versions, active attempt, локальная ошибка и серверная ошибка. Не начинайте с общего объекта «всё состояние формы».
  3. Сохраните снимок. В момент submit запишите values, версии и новый attemptId. Не вычисляйте их заново при получении ответа.
  4. Закройте повтор. Пока attempt активен, второй submit должен получить явный результат вроде retry-blocked-active-attempt или попасть в заранее определённую очередь.
  5. Проверьте право ответа. Сначала сравните attemptId. Затем сравните версии полей. Только после этого применяйте server error или success.
  6. Очистите старое сообщение. Новая версия поля должна убрать связанную server error. Не удаляйте введённое значение только потому, что сервер вернул отказ.
  7. Соберите semantic payload. Свяжите поле, состояние invalid, идентификатор текста и понятное действие. Проверьте реальную разметку, а не только JavaScript-объект.
  8. Проверьте отрицательный путь. Передайте ответ старой попытки после правки. Убедитесь, что значение, сообщение и semantic association не изменились.
\n

Где модель заканчивается

\n

Контракт с версиями подходит для независимых полей и обычной повторной отправки. Он не решает автоматически зависимые поля, загрузку файлов, debounce, optimistic update, несколько вкладок и автоматические retry. Для каждого такого механизма нужно описать, какой снимок считается актуальным и кто может закрыть попытку.

\n

Локальный rollback также имеет узкую границу. Он может вернуть поле к последнему принятому локальному снимку. Он не отменяет запись на сервере и не делает компенсирующий запрос. Для платежа, заказа или изменения прав нужна отдельная серверная операция, правила конфликта и аудит. Нельзя назвать возврат значения в input отменой доменного действия.

\n

Доступность требует отдельной проверки. W3C требует идентифицировать поле с ошибкой и описать её текстом, но наличие объекта fieldError не заменяет проверку DOM, клавиатуры и выбранных вспомогательных технологий. Не обещайте результат, который не измеряли.

\n

Проверяемый критерий готовности

\n

Контракт готов, если автоматическая проверка проходит четыре отрицательные ветки: локально невалидное значение не создаёт попытку; повтор поверх активной попытки не создаёт вторую; ответ со старой версией не меняет текущую ошибку; повторная доставка уже применённого ответа не меняет состояние. Отдельная проверка реального UI должна подтверждать сохранение введённых данных, связь поля с текстом ошибки и восстановление фокуса по правилам продукта.

\n

Если проходит только успешная отправка, работа не закончена. Успех не проверяет причинную связь. Готовность означает, что каждый показанный текст можно связать с конкретным полем, конкретной попыткой и текущим значением, а старый результат не способен переписать новый ввод.

\n

Проверяемые источники

\n" }