{ "index": 201, "slug": "editorial-2022-06-practice-form-errors", "title": "Старая ошибка формы после новой правки: как связать ответ с тем вводом, который сервер проверял", "excerpt": "Если пользователь исправил поле, поздний ответ прежней отправки не должен менять текущую форму. Разбираем локальную проверку, версии полей, attemptId и честный retry.", "contentHtml": "
Пользователь вводит old@example.test и нажимает «Сохранить». Сервер отвечает: «Адрес уже занят». Пока ответ идёт, пользователь меняет поле на new@example.test. Затем интерфейс показывает ту же ошибку рядом с новым адресом. Поле выглядит заполненным, но сообщение относится к другому значению.
Цена ошибки измерима на уровне действия. Человек может повторно исправлять корректный адрес, закрыть форму или отправить её ещё раз, не понимая, какой запрос сейчас активен. В регистрациях, платежах и заказах такое поведение подрывает доверие и может привести к повторному действию с побочным эффектом.
\nТезис: ошибка принадлежит не имени поля, а снимку ввода и конкретной попытке отправки. Поэтому ответ можно применить только тогда, когда он относится к текущей версии поля. Для этого нужно разделить локальную проверку, состояние отправки и результат сервера; при каждой правке увеличивать версию поля; каждой отправке выдавать attemptId; устаревший ответ явно игнорировать.
У формы есть два времени. Первое — время ввода. Значение меняется при каждом действии пользователя. Второе — время ответа. Запрос может закончиться после нескольких новых правок. Если обработчик знает только имя поля, он записывает результат в актуальное состояние, хотя сервер проверял прежний снимок.
\nПравило «применяем последний пришедший ответ» не защищает форму: последним может прийти самый старый запрос. Правило «последняя правка побеждает» тоже неполно: оно способно скрыть ошибку, которая действительно относится к текущему значению. Нужна проверка принадлежности: совпадают ли версия поля и идентификатор попытки с теми, что сохранены в состоянии.
\nЛокальная ошибка имеет другой источник. Пустое обязательное поле или строку без доменной части email можно проверить до отправки. В этом случае запрос не создаётся. Серверная ошибка появляется только после ответа на конкретный снимок. Ошибка транспорта описывает доставку ответа, а не значение одного поля. Если положить всё в form.error, код потеряет правило очистки и следующий шаг.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старый текст появился после правки | Ответ сопоставлен только по имени поля | Сравнить версии из запроса и текущего поля | Пометить ответ как stale и не создавать server error |
| Две отправки идут одновременно | Retry не проверяет active attempt | Проверить переходы submitting и retry | Заблокировать повтор до завершения попытки |
| Локальная ошибка ушла на сервер | Constraint validation смешана с submit | Убедиться, что невалидный снимок не получил attemptId | Показать причину у поля и остановить отправку |
| Ошибка осталась после изменения значения | Server error очищается только после ответа | Изменить поле и проверить state до нового ответа | Снять ошибку прежней версии |
| Повторный ответ меняет успех | Нет границы принятой попытки | Передать duplicate после success | Игнорировать ответ без active attempt |
Минимальная запись поля может выглядеть так: { value, version, localError, serverError }. В состоянии формы отдельно нужны activeAttempt и последний принятый снимок. При правке меняются value и version, а ошибка сервера для прежней версии исчезает. При отправке код копирует значения и версии в attempt. Он не должен читать актуальное поле в момент ответа: это уже может быть другой ввод.
function editEmail(form, value) {\n const field = form.fields.email;\n return { ...form, fields: { ...form.fields, email: {\n value, version: field.version + 1,\n localError: validateEmail(value), serverError: null\n }}};\n}\n\nfunction beginSubmit(form) {\n const email = form.fields.email;\n if (email.localError) return { kind: 'blocked-local-error' };\n const attempt = { id: form.nextAttemptId,\n values: { email: email.value }, versions: { email: email.version } };\n return { ...form, activeAttempt: attempt, nextAttemptId: attempt.id + 1 };\n}\n\nfunction receiveResult(form, result) {\n const attempt = form.activeAttempt;\n if (!attempt || attempt.id !== result.attemptId) return ignore(form);\n const current = form.fields.email;\n if (attempt.versions.email !== current.version) {\n return { ...form, activeAttempt: null, log: [...form.log, 'stale-response-ignored'] };\n }\n if (!result.ok) return { ...form, activeAttempt: null,\n fields: { ...form.fields, email: { ...current, serverError: result.message } } };\n return { ...form, activeAttempt: null, accepted: attempt.values };\n}\nЭто учебный пример для in-memory reducer. Он не отправляет HTTP-запрос, не отменяет fetch, не создаёт DOM и не доказывает порядок сетевых пакетов. Строка Адрес уже занят в тесте должна быть входом result.message, а не утверждением о production-ответе. В реальном приложении сетевой адаптер обязан передать в reducer тот же attemptId и снимок версий.
Устаревший ответ нельзя применять к полю. Но его нельзя и просто забыть, оставив форму в вечном pending. Если пользователь уже ввёл новое значение, активная попытка прежнего снимка больше не должна блокировать текущий retry. Переход stale-response-ignored снимает activeAttempt, пишет диагностический сигнал и не создаёт ошибку.
После этого пользователь может отправить текущий валидный снимок. Новый retry получает новый attemptId и новые версии. Это не означает, что браузер отменил старый запрос. Отмена зависит от API и стоимости работы сервера. Даже отменённый запрос не заменяет проверку ответа: гонки и повторная доставка должны иметь безопасный результат.
Состояние данных не заменяет доступную разметку. Если интерфейс использует aria-invalid и aria-errormessage, атрибуты должны появляться только для ошибки текущей версии. При новой правке связь с прежним сообщением нужно убрать. Идентификатор попытки сам по себе не делает сообщение доступным и не подтверждает, что вспомогательная технология его объявила.
Для нативных контролов сначала используйте возможности HTML: правильный тип, required, pattern и constraint validation. Скрипт может дополнить этот слой серверным результатом, но не должен подменять его общей строкой без указания поля. Сообщение должно объяснять причину и действие, если сервер действительно сообщает о проблеме текущего снимка.
Схема рассчитана на одну форму и одного владельца состояния. Она не решает конфликт между двумя вкладками, offline, rate limit, CSRF, составные поля, загрузку файлов и оптимистическое сохранение. Для нескольких полей версия должна быть частью снимка каждого поля или общей версии формы; выбор зависит от того, какие поля сервер проверяет вместе.
\nattemptId интерфейса не является idempotency key. Он защищает применение ответа внутри формы. Идемпотентность на сервере требует отдельного контракта. Нельзя считать, что новый UI-идентификатор предотвращает повторную оплату или создание ресурса.
Пример не содержит production-метрик. Его проверяемый результат уже: устаревший ответ не меняет текущий ввод; совпавший ответ меняет только соответствующий снимок; duplicate после success не меняет accepted state; локально невалидный ввод не создаёт попытку.
\nИсправление готово, если команда воспроизводит четыре сценария с однозначными переходами: локальная ошибка блокирует submit без attemptId; правка во время отправки делает прежний ответ stale; совпавший server error появляется только у проверенного значения; успешный retry создаёт новый accepted snapshot, а старый duplicate ничего не меняет. Браузерная проверка дополнительно подтверждает, что сообщение видно, связано с нужным контролом и очищается после новой правки.
\naria-invalid и связь ошибки через aria-errormessage.