{ "index": 314, "slug": "editorial-2019-04-mechanism-forms-validation", "title": "Валидация формы: кто принимает решение и как не показать старую ошибку", "excerpt": "Клиент быстро проверяет формат, сервер принимает окончательное решение, а асинхронный ответ должен относиться к текущему значению поля. Разбираем состояния формы, гонку запросов и проверяемый путь от input до submit.", "contentHtml": "

Пользователь вводит ivan, получает сообщение «логин занят», меняет значение на ivanka, а через мгновение видит ту же ошибку. Другой вариант: поле подсвечено зелёным, кнопка отправки активна, но API отклоняет запрос. Цена ошибки — потерянное время, повторные попытки и недоверие к форме. Для команды это ещё и лишние флаги: isValid, isLoading и error начинают описывать разные значения одного поля.

\n

Тезис: валидация формы — это договор между браузером, клиентским состоянием и сервером. Браузер быстро проверяет ограничения HTML. Клиент показывает понятный результат и связывает его с полем. Сервер проверяет вход снова и решает, можно ли сохранить данные. Каждый асинхронный результат должен нести версию значения, для которого он получен. Поздний ответ старой версии нужно отбросить.

\n

Три уровня проверки

\n

Встроенная проверка ограничений (constraint validation) отвечает на локальный вопрос: заполнено ли обязательное поле, соответствует ли значение типу, проходит ли длину или pattern. Эти правила доступны через Constraint Validation API. Они удобны для ранней обратной связи и не требуют запроса.

\n

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

\n

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

\n

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

\n

Состояние поля вместо одного boolean

\n

Для примера возьмём поле логина. Его состояние содержит value, номер версии version, локальную ошибку localError, ошибку API remoteError и фазу. Фаза может быть editing, client-invalid, checking, remote-invalid или ready.

\n

Версия растёт на каждом изменении значения. Ответ проверки сохраняет номер версии при отправке запроса. При завершении обработчик сравнивает этот номер с текущим. Такое сравнение защищает состояние от гонки: скорость ответа больше не определяет, какая ошибка останется на экране.

\n
Состояния одного поля
ФазаЧто означаетЧто можно показатьДопустимый переход
editingЗначение меняется или ещё не проверено удалённоТекущее значение и подсказкаinput → локальная проверка
client-invalidНарушено локальное правилоТекст ошибки у поляinput → editing или новая проверка
checkingЗапрос относится к текущей версии«Проверяем…» без старой ошибкиответ той же версии → ready или remote-invalid
remote-invalidAPI отклонил текущую версиюОтвет API у поляinput → editing
readyИзвестные проверки пройденыРазрешение продолжитьinput → editing; submit → отправка
\n

Таблица нужна не для отображения названий фаз. Она запрещает противоречия. Поле не должно одновременно быть ready и хранить ошибку для старого значения. checking не означает, что сервер уже разрешил отправку. После нового input старая ошибка API больше не описывает экран.

\n

Локальная проверка и граница сервера

\n

Нативные ограничения задают на элементе формы. Пример ниже учебный: регулярное выражение показывает простое правило для логина и не утверждает, что так устроен production API. Реальный контракт может разрешать Unicode, нормализовать регистр или применять дополнительные ограничения.

\n
const initialState = {\n  value: '',\n  version: 0,\n  phase: 'editing',\n  localError: '',\n  remoteError: '',\n};\n\nfunction localLoginError(value) {\n  if (!value) return 'Введите логин';\n  if (!/^[a-z0-9_]{3,20}$/i.test(value)) {\n    return 'От 3 до 20 букв, цифр или _';\n  }\n  return '';\n}\n\nfunction onInput(state, nextValue) {\n  const localError = localLoginError(nextValue);\n\n  return {\n    value: nextValue,\n    version: state.version + 1,\n    phase: localError ? 'client-invalid' : 'editing',\n    localError,\n    remoteError: '',\n  };\n}\n\nfunction beginRemoteCheck(state) {\n  return {\n    nextState: { ...state, phase: 'checking', remoteError: '' },\n    request: { value: state.value, checkedVersion: state.version },\n  };\n}
\n

Обработчик увеличивает версию и очищает remoteError в одном переходе. Нельзя оставить сообщение «логин занят» рядом с новым значением, а потом надеяться, что следующий ответ его исправит. Экран должен перестать утверждать старый факт сразу после ввода.

\n

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

\n

Защита от устаревшего ответа

\n

При старте запроса сохраните checkedVersion. В момент ответа получите актуальное состояние из владельца формы и сравните номера. Нельзя сравнивать ответ со старым объектом из замыкания: такой объект всегда может совпасть сам с собой.

\n
function applyRemoteAnswer(current, result) {\n  if (current.version !== result.checkedVersion) {\n    return current; // учебный пример: ответ устарел\n  }\n\n  return {\n    ...current,\n    phase: result.available ? 'ready' : 'remote-invalid',\n    remoteError: result.available ? '' : 'Этот логин уже занят',\n  };\n}\n\n// Учебный порядок ответов:\n// request 1: value=ivan, version=1, available=false\n// request 2: value=ivanka, version=2, available=true\n// request 2 может прийти первым; request 1 не меняет finalState.
\n

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

\n

Сетевой сбой — отдельный результат, а не remote-invalid. Сохраните его как transportError или состояние общего блока формы, предложите повторить запрос и не стирайте текущее значение. Иначе пользователь увидит «логин занят», хотя сервер этого не сообщал.

\n

AbortController полезен, когда транспорт умеет отменять ненужный запрос. Он экономит ресурсы, но не заменяет сравнение версий: отмена может прийти поздно, сервер может уже обработать запрос, а другой адаптер может игнорировать сигнал.

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

Симптом → причина → проверка → действие

\n
Диагностика ошибок валидации
СимптомПричинаПроверкаДействие
Старая ошибка возвращается после нового вводаОтвет не связан с версией значенияЗамедлить первый запрос и проверить порядок ответовДобавить version и отбрасывать устаревший ответ
Кнопка активна до окончания проверкиisValid учитывает только локальный форматОтправить форму в фазе checkingЖдать проверку или валидировать условие на submit
API отказал зелёному полюКлиент принял локальное правило за серверный контрактСравнить тело запроса и код/ключ ошибки APIПоказать серверную ошибку и оставить сервер источником истины
Ошибка видна только рамкойНет текста и связи сообщения с контроломПройти поле клавиатурой и проверить дерево доступностиДобавить label, видимый текст и связь по ID
Неизвестная ошибка исчезаетКлиент пытается приклеить неизвестный ключ к случайному полюВернуть от API ошибку без известного имени поляПоказать общий блок формы и сохранить техническую диагностику
\n

Submit — отдельный переход

\n

Отправка формы не равна проверке поля. Сначала браузер может выполнить встроенную проверку ограничений (constraint validation). Затем клиент решает, есть ли локальные ошибки и незавершённые проверки. После этого запрос сохранения уходит на сервер, который снова проверяет весь вход.

\n

Для фазы checking выберите одну политику. Можно дождаться текущей проверки. Можно разрешить submit и принять окончательный ответ API. Можно временно отключить кнопку, если интерфейс объясняет причину и не блокирует исправление. Нельзя показывать готовность только потому, что регулярное выражение прошло.

\n

Для уникального логина окончательная проверка должна быть связана с сохранением: ограничение уникальности в базе данных должно отклонить конфликт. Предварительный запрос «логин свободен» не резервирует логин. Другой пользователь может занять его до submit. Ответ сохранения имеет приоритет над предварительным ответом.

\n

Ошибка должна быть доступна

\n

У поля есть видимое имя через label. Подсказка и сообщение об ошибке получают устойчивые ID. При ошибке контрол получает aria-invalid=\"true\", а связь с сообщением задаётся атрибутом описания или сообщения об ошибке. Если используется aria-errormessage, при актуальной ошибке сообщение должно быть видимым; после исправления его нужно скрыть или убрать этот атрибут. Цвет рамки остаётся дополнительным сигналом.

\n
<label for=\"login\">Логин</label>\n<input id=\"login\"\n       name=\"login\"\n       aria-invalid=\"true\"\n       aria-errormessage=\"login-error\"\n       aria-describedby=\"login-hint\" />\n<div id=\"login-hint\">От 3 до 20 символов.</div>\n<div id=\"login-error\">Этот логин уже занят.</div>
\n

Это учебный фрагмент разметки. После интеграции проверьте, что сообщение действительно отображается, связь не дублируется и фокус остаётся понятным после submit. Не добавляйте role=\"alert\" на всю форму: длинное сообщение создаёт шум. Срочное изменение статуса должно быть коротким и уместным.

\n

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

\n
  1. Выписать поля формы и разделить правила на локальные, серверные и общие для формы.
  2. Назвать владельца состояния. Хранить value, фазу, ошибки и версию в одном согласованном месте.
  3. Добавить нативные ограничения, если они честно описывают контракт: required, тип, длину или pattern.
  4. На каждом input увеличивать версию, пересчитывать локальную ошибку и очищать старую ошибку API.
  5. Перед сетевой проверкой сохранить версию. В ответе сравнить её с актуальным состоянием до изменения UI.
  6. Явно решить поведение submit в фазе checking. Не считать незавершённую проверку готовностью.
  7. Связать label, подсказку и ошибку с контролом. Проверить клавиатуру, фокус и текст, а не только цвет.
  8. Проверить отрицательные случаи: старый ответ после нового ввода, неизвестный ключ API, ошибка сети и отказ сохранения при предварительно свободном значении.
  9. Записать критерий готовности и границу возврата. Если команда не может повторить проверку, форма не готова.
\n

Ограничения

\n

Версия защищает состояние интерфейса, но не делает запрос идемпотентным и не защищает базу от конкурирующей записи. Серверная проверка остаётся обязательной.

\n

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

\n

Сложная форма может иметь автомат формы и автоматы отдельных полей. Это не отменяет явной связи: submit должен знать, какие поля ещё проверяются и кто возвращает окончательный отказ.

\n

Учебные логины, задержки и результаты в коде не являются production-измерениями. Они показывают порядок переходов и отрицательный путь. Перед выпуском нужны реальные ответы API, браузерная проверка и проверка доступности.

\n

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

\n

Форма готова, если для каждого поля можно назвать владельца значения, локальное правило, серверное условие, фазу, номер версии и место сообщения. При двух ответах в обратном порядке старый ответ не меняет новое значение. При отказе API ошибка появляется у правильного поля или в общем блоке, если ключ неизвестен. При клавиатурной проверке поле имеет имя, текст ошибки доступен и фокус не теряется.

\n

Достаточное доказательство — воспроизводимый сценарий с пустым полем, неверным форматом, текущей серверной ошибкой, устаревшим ответом и отказом submit. Если хотя бы один сценарий оставляет на экране вердикт для другого значения, правило актуальности не внедрено.

\n

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

\n" }