{ "index": 199, "slug": "editorial-2022-06-field-form-errors", "title": "Когда форма показывает старую ошибку: snapshots, retry и безопасный rollback", "excerpt": "Поздний ответ сервера может вернуть ошибку уже исправленного поля и стереть полезный контекст. Разбираем связь между значениями, попыткой и ответом, а затем проверяем retry, duplicate и локальный rollback.", "contentHtml": "

Пользователь исправляет email и нажимает «Отправить». Через секунду форма снова показывает ошибку для старого значения. Иногда рядом появляется второй результат: кнопка разрешила два retry, а после успеха старый ответ снова очистил поле. Цена ошибки — не только раздражение. Человек повторяет уже правильное действие, теряет введённые данные и может несколько раз отправить одну операцию.

\n

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

\n

Надёжное правило простое: ответ меняет форму только после проверки attemptId и версии значений. Локальный retry создаёт новую попытку. Локальный rollback возвращает последний принятый снимок, но не отменяет запись на сервере. Эти действия должны иметь разные переходы и разные проверки.

\n

Наблюдаемый сбой и его механизм

\n

Рассмотрим форму с одним полем email. В момент отправки приложение сохраняет не копию ссылки на объект, а неизменяемый снимок: значение, версию поля и идентификатор попытки. Пусть текущий снимок имеет emailVersion = 4, а отправка получает attemptId = 17. Пользователь меняет email. Версия становится 5, но ответ попытки 17 всё ещё несёт версию 4.

\n

Если обработчик сравнивает только имя поля, он применит старую ошибку к новому значению. Если обработчик сравнивает только attemptId, он не увидит, что поле уже изменилось. Нужны обе проверки. Ответ старой попытки с несовпадающей версией получает статус stale-response-ignored и не создаёт сообщение в интерфейсе.

\n

Серверная ошибка и локальная ошибка тоже решают разные задачи. Браузер может остановить submit из-за пустого или неверно записанного email. Сервер может отклонить уже синтаксически правильное значение, например потому, что адрес занят. В первом случае запрос не должен создаваться. Во втором сообщение должно быть связано с конкретным полем и объяснять следующий шаг.

\n
Диагностика ошибки формы
СимптомПричинаПроверкаДействие
Старая ошибка вернулась после editОтвет сопоставили только с именем поляСравнить версию снимка и текущую версиюПометить ответ устаревшим и не менять UI
Два retry ушли подрядХранится только boolean loadingНайти активный attemptId в stateОтклонить второй retry до завершения первой попытки
Ошибка есть, но поле не названоОбщий toast заменил связь с контроломПроверить field, текст и идентификатор сообщенияПоказать ошибку у поля и общий статус отдельно
Успех применился дваждыЗавершённая попытка осталась активнойПовторно передать тот же ответИгнорировать duplicate без изменения принятого снимка
Rollback обещает отмену операцииЛокальный undo смешали с API-компенсациейПроверить, вызывает ли rollback сетевой adapterОграничить его локальными значениями или описать отдельный API
\n

Три состояния, которые нельзя заменять одним флагом

\n

Первое состояние — текущие значения формы. Они меняются при вводе. Второе — активная попытка с собственным идентификатором и снимком версий. Третье — последний принятый снимок. Он нужен, если интерфейс предлагает вернуть локальное состояние после неудачной правки. У каждого состояния свой жизненный цикл.

\n

При submit приложение сначала проверяет локальные ограничения. Если email некорректен, оно показывает ошибку и не создаёт попытку. Если попытка уже активна, retry останавливается. Иначе создаётся новый снимок. Ответ можно применить только при совпадении идентификатора и версий. После успеха попытка закрывается, а её значения становятся последним принятым снимком.

\n

При изменении поля версия увеличивается. Это важнее, чем очистить старый текст через setError(null). Очистка меняет видимый результат, но не создаёт доказательство, что поздний callback больше не подходит. Версия даёт обработчику такое доказательство.

\n
type Snapshot = {\n  values: { email: string };\n  versions: { email: number };\n  attemptId: number;\n};\n\nfunction acceptResponse(state, response) {\n  const active = state.activeAttempt;\n  if (!active || active.attemptId !== response.attemptId) {\n    return { ...state, log: [...state.log, 'duplicate-or-unknown'] };\n  }\n  if (active.snapshot.versions.email !== state.versions.email) {\n    return { ...state, activeAttempt: null, log: [...state.log, 'stale-response-ignored'] };\n  }\n  if (response.ok) {\n    return {\n      ...state,\n      activeAttempt: null,\n      accepted: active.snapshot,\n      error: null\n    };\n  }\n  return {\n    ...state,\n    activeAttempt: null,\n    error: { field: 'email', message: response.message }\n  };\n}
\n

Это учебный фрагмент reducer. Он не выполняет HTTP-запрос, не задаёт формат API и не доказывает exactly-once доставку. Его задача — показать границу, на которой интерфейс решает, может ли ответ изменить локальное состояние. В реальном компоненте ответ адаптера должен попасть в этот же контракт, а не напрямую вызвать очистку поля.

\n

Схема переходов

\n
\"Схема
Ответ проверяет попытку и версию до того, как меняет форму. Rollback возвращает только локальный принятый снимок.
\n

На схеме важна отрицательная ветка. Устаревший ответ не является новой ошибкой пользователя. Он может попасть в технический журнал, но не должен снова показываться в поле. Duplicate тоже не является новым успехом. После закрытия попытки повторный callback не имеет активного адресата.

\n

Пример последовательности

\n

Сначала пользователь вводит неправильный email. Локальная проверка выставляет aria-invalid, связывает сообщение с контролом и блокирует submit. Затем пользователь исправляет значение. Приложение очищает сообщение, увеличивает версию и создаёт попытку 18 со снимком версии 5.

\n

Пока попытка 18 активна, второй клик не создаёт попытку 19. Это не только защита от двойного клика. Новый идентификатор усложнил бы причинную связь: оба ответа могли бы менять одно поле, а порядок callback не обязан совпадать с порядком кликов.

\n

После изменения email версия становится 6. Ответ попытки 18 приходит с версией 5 и получает stale-response-ignored. Пользователь не видит старое сообщение. Если сервер должен проверить новое значение, retry создаёт новую попытку 19 и новый снимок. Успешный ответ закрывает её и сохраняет принятые значения.

\n

Если тот же успешный ответ приходит повторно, обработчик не должен повторно очищать ошибки, запускать переход или менять accepted snapshot. Такой ответ можно учесть в локальном журнале как duplicate. Это проверка идемпотентности reducer, а не гарантия сети.

\n
  1. Зафиксируйте симптом. Выберите один путь: edit после submit, два retry или повторный ответ. Запишите порядок событий и видимый текст.
  2. Снимите входы. Сохраните значения и версии в момент submit. Без этого нельзя доказать, что ответ устарел.
  3. Разделите локальную и серверную проверку. Некорректное поле не создаёт попытку. Серверный отказ приходит только после принятого локального ввода.
  4. Добавьте активную попытку. Храните attemptId и snapshot. Второй retry должен останавливаться до создания нового идентификатора.
  5. Проверяйте ответ в два шага. Сначала сравните attemptId, затем версии. Только после этого применяйте error или success.
  6. Свяжите ошибку с полем. Храните имя поля, понятный текст и следующий шаг. Общий статус формы не заменяет сообщение у контрола.
  7. Отделите rollback. Возвращайте последний локальный accepted snapshot, увеличивайте версии и разрешайте действие один раз. Не называйте это отменой серверной операции.
  8. Проверьте настоящий интерфейс. Пройдите клавиатурой и в целевых браузерах. Отдельно проверьте фокус, объявление сообщения и сетевой adapter.
\n

Ограничения

\n

Эта модель не решает upload, debounce, optimistic update, автоматические повторы, отмену HTTP, несколько вкладок и составные операции. У них есть дополнительные идентификаторы, границы владения и правила конфликтов. Для заказа, платежа или изменения прав локального rollback недостаточно: нужен серверный статус, политика отмены и журнал операции.

\n

Учебный код не моделирует реальную сеть, задержку, браузер, screen reader или production telemetry. Положительный результат его проверок означает только то, что перечисленные переходы reducer соблюдают заданные правила. Он не подтверждает доступность готового UI и не измеряет число ошибок пользователей.

\n

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

\n

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

\n

Если хотя бы один переход проверяется только через happy path, причина сбоя остаётся недоказанной. Сначала зафиксируйте контракт состояния. Потом подключайте транспорт и проверяйте его отдельно. Так сообщение формы остаётся следствием актуального ввода, а не случайного порядка callback.

\n

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

\n" }