{ "index": 200, "slug": "editorial-2022-06-mechanism-form-errors", "title": "Ошибка формы должна относиться к тому вводу, который человек видит", "excerpt": "Как связать значение поля, попытку отправки и сообщение об ошибке, чтобы поздний ответ сервера не переписал исправленный ввод.", "contentHtml": "
Человек вводит неправильный email и нажимает «Отправить». Форма отправляет запрос. Пока сервер отвечает, человек исправляет адрес и нажимает кнопку снова. Затем рядом с новым адресом появляется сообщение «Адрес уже занят», хотя сервер проверял старое значение. Визуально форма показывает ошибку. По смыслу она лжёт.
\nЦена ошибки выше, чем стоимость ещё одного текста под полем. Пользователь может исправить правильный адрес, повторить отправку несколько раз или решить, что сервис потерял данные. Поддержка получает неясный вопрос. Команда видит случайный дефект: он появляется только при определённом порядке ввода и ответов.
\nТезис статьи прост: сообщение об ошибке, поле, которое оно описывает, и попытка отправки должны принадлежать одному снимку данных. Нельзя применять ответ только потому, что он пришёл последним или называет то же поле. Надёжная форма хранит attemptId, версии значений и отдельный semantic payload. Старый ответ можно показать в журнале диагностики, но нельзя возвращать его в текущее поле.
У формы есть как минимум два времени. Первое — время ввода: значение email меняется с каждым редактированием. Второе — время запроса: сервер получает снимок и отвечает позже. Эти часы не обязаны идти в одном порядке. Ответ первой попытки может прийти после ответа второй.
\nНаивный обработчик часто выглядит так:
\nfunction onServerResult(result) {\n if (result.errors.email) {\n state.emailError = result.errors.email;\n }\n}\n\n// Любой поздний ответ может переписать состояние текущего поля.\n\nВ этом коде нет связи между result и значением, которое сейчас лежит в поле. Имя email совпадает, но снимки могут различаться. Обработчик знает только содержание ответа, а не его право менять состояние.
Вторая ошибка — складывать разные причины в одну строку form.error. Локальная проверка отвечает на вопрос «можно ли отправлять этот ввод». Серверная проверка отвечает на вопрос «принял ли сервер конкретный снимок». Ошибка сети отвечает на вопрос «доставлен ли результат». У этих сигналов разные владельцы и срок жизни.
При каждой правке поля увеличивайте его version. Перед отправкой сохраните неизменяемый снимок значений и версий. Одновременно выдайте попытке новый attemptId. Ответ имеет право изменить форму только тогда, когда совпали оба условия: попытка ещё активна, а версии ответа совпадают с текущими версиями проверенных полей.
const form = {\n values: { email: 'person@example.test' },\n versions: { email: 1 },\n activeAttempt: null,\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: 1,\n values: { ...form.values },\n versions: { ...form.versions },\n };\n form.activeAttempt = attempt;\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 (result.versions.email !== form.versions.email) {\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, не измеряет задержку и не доказывает порядок сетевых пакетов. Значение attemptId: 1 здесь не является серверным идентификатором и не заменяет ключ идемпотентности API. Пример проверяет только право ответа изменить локальное состояние.
При новой правке серверная ошибка очищается сразу. Это не означает, что сервер уже принял новое значение. Это означает, что старое сообщение больше не относится к текущему вводу. После этого пользователь может отправить новый снимок. Если старый ответ придёт позднее, сравнение версий остановит его.
\nТекст сам по себе не даёт полю семантической связи. Компонент должен знать, какое поле не прошло проверку, какой элемент содержит описание и какое действие ожидается от пользователя. Удобно собирать эти данные одним переходом состояния:
\nconst 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-контракту. Если после новой правки атрибут остался, а текст относится к старому ответу, интерфейс всё ещё сообщает неверную причину. И наоборот: корректный объект в unit-тесте не доказывает, что реальный DOM связывает элементы именно так или что конкретная вспомогательная технология озвучит их ожидаемым образом.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старое сообщение появляется после правки | Ответ сопоставляют только по имени поля | Сравнить версии снимка и текущего значения | Пометить ответ stale и не менять ошибку |
| Вторая отправка создаётся поверх первой | Состояние хранит только boolean loading | Проверить активный attempt до создания нового | Заблокировать повтор или явно определить очередь |
| Ошибка есть, но поле не названо | Серверный текст потерял field key | Найти источник сообщения и его идентификатор | Хранить field-specific record отдельно от общего статуса |
| Красный текст остался после исправления | Очистка зависит от submit, а не от edit | Изменить поле после ошибки и посмотреть state | Сбрасывать связанную server error при новой версии |
| Один ответ применился дважды | Обработчик не закрывает active attempt | Повторно передать тот же result | Игнорировать ответ без активной попытки |
| Форма очищается после отказа | UI заменяет values ответом или пересоздаёт модель | Сравнить values до submit и после error | Хранить снимок и возвращать пользователю введённые данные |
На схеме важна граница stale response. Ответ не уничтожается и не считается ошибкой транспорта. Он просто теряет право менять пользовательский state. Это отрицательный путь, который должен быть виден в тесте. Без него happy path легко создаёт ложное ощущение готовности.
\nretry-blocked-active-attempt или попасть в заранее определённую очередь.Контракт с версиями подходит для независимых полей и обычной повторной отправки. Он не решает автоматически зависимые поля, загрузку файлов, debounce, optimistic update, несколько вкладок и автоматические retry. Для каждого такого механизма нужно описать, какой снимок считается актуальным и кто может закрыть попытку.
\nЛокальный rollback также имеет узкую границу. Он может вернуть поле к последнему принятому локальному снимку. Он не отменяет запись на сервере и не делает компенсирующий запрос. Для платежа, заказа или изменения прав нужна отдельная серверная операция, правила конфликта и аудит. Нельзя назвать возврат значения в input отменой доменного действия.
\nДоступность требует отдельной проверки. W3C требует идентифицировать поле с ошибкой и описать её текстом, но наличие объекта fieldError не заменяет проверку DOM, клавиатуры и выбранных вспомогательных технологий. Не обещайте результат, который не измеряли.
Контракт готов, если автоматическая проверка проходит четыре отрицательные ветки: локально невалидное значение не создаёт попытку; повтор поверх активной попытки не создаёт вторую; ответ со старой версией не меняет текущую ошибку; повторная доставка уже применённого ответа не меняет состояние. Отдельная проверка реального UI должна подтверждать сохранение введённых данных, связь поля с текстом ошибки и восстановление фокуса по правилам продукта.
\nЕсли проходит только успешная отправка, работа не закончена. Успех не проверяет причинную связь. Готовность означает, что каждый показанный текст можно связать с конкретным полем, конкретной попыткой и текущим значением, а старый результат не способен переписать новый ввод.
\nattemptId и version.aria-invalid и aria-errormessage; статья не выдаёт учебный payload за доказательство доступности готового интерфейса.