DarkRiDDeR13 мин

Валидация формы как конечный автомат: состояние, версия и ошибка поля

JavaScriptHTMLАрхитектура

Симптом: у формы есть isValid, isLoading и строка error, но после двух быстрых изменений поля кнопка становится активной в неправильный момент, а поздний ответ возвращает старый текст. Цена такой ошибки — не только лишний запрос. Пользователь видит состояние, которое относится к уже несуществующему значению, и команда начинает добавлять ещё один флаг вместо объяснения переходов.

Причина в том, что валидация — это не один boolean. У поля есть значение, локальная проверка, ожидание ответа, серверная ошибка, успешная готовность и попытка отправки. В 2019 для небольшой формы не нужен отдельный автоматный фреймворк. Нужна простая таблица переходов и правило актуальности: обработчик асинхронного результата меняет состояние только тогда, когда его версия совпадает с текущей версией ввода.

Какие данные принадлежат состоянию

Начнём с одного поля логина. Значение value — то, что редактирует человек. touched помогает решить, когда впервые показывать локальную ошибку. localError отвечает за синтаксис и обязательность. remoteError приходит от условия, которое знает сервер, например «логин занят». version растёт при каждом изменении value. Наконец, phase делает состояние читаемым: editing, client-invalid, checking, remote-invalid или ready.

Не все поля обязаны иметь такую же схему. Пароль может не требовать сетевой проверки, а форма как целое дополнительно имеет submitting, submit-failed и submitted. Важна не одинаковость, а явная граница. Если один флаг одновременно означает «строка соответствует pattern», «запрос уже ушёл» и «сервер согласен», он неизбежно станет неправдой в одном из промежуточных моментов.

Состояния одного поля и разрешённые действия
ФазаЧто видно пользователюКакие данные достоверныСледующий переход
editingТекущее значение, без окончательного вердиктаЗначение и его versioninput → локальная проверка
client-invalidОшибка формата или обязательностиlocalError; сетевой запрос не нуженinput → editing или проверка
checkingКороткое «Проверяем…», поле всё ещё можно менятьversion запроса и очищенная старая remoteErrorответ той же версии → ready или remote-invalid
remote-invalidСерверный текст у поляremoteError только для текущего valueinput → editing
readyЗначение прошло известные проверкиЛокальная и текущая remote-проверкаinput → editing; submit → submitting формы

Эта таблица не требует показывать пользователю слово phase. Она нужна разработчику, чтобы заранее исключить нелепые комбинации: ready и старый remoteError, checking для пустой строки, client-invalid после положительного ответа другой версии. При чтении кода полезно задавать один вопрос: какое событие имеет право перевести поле из этой фазы в следующую?

Переход input сначала очищает старый результат

Когда человек меняет символ, прежний серверный ответ больше не характеризует значение. Поэтому переход input увеличивает version, очищает remoteError и возвращает поле в editing или client-invalid. Ошибка «логин занят» не должна оставаться рядом с ivanka, если она относилась к ivan. Это простое правило часто важнее debounce: даже если запрос ещё не сделан, экран уже не показывает вердикт для старого входа.

function localLoginError(value) {
  if (!value) return 'Введите логин';
  if (!/^[a-z0-9_]{3,20}$/i.test(value)) {
    return 'От 3 до 20 букв, цифр или _';
  }
  return '';
}

function onInput(state, nextValue) {
  var localError = localLoginError(nextValue);

  return {
    value: nextValue,
    version: state.version + 1,
    phase: localError ? 'client-invalid' : 'editing',
    localError: localError,
    remoteError: '',
  };
}

Состояние здесь является обычным объектом. Его легко использовать и с jQuery-формой, и с React, и с собственным рендером. Регулярное выражение — пример локального UX-правила, а не обещание, что сервер принимает те же символы. Если сервер нормализует регистр или допускает Unicode, этот факт должен быть отдельно описан в API-контракте. Клиенту не следует придумывать более строгую форму, которая запрещает корректные серверные данные.

Асинхронный переход должен нести версию

После локально корректного input можно начать асинхронную проверку. Обработчик сохраняет checkedVersion до вызова API. Когда promise завершается, он сравнивает сохранённую версию с текущей. Несовпадение означает не «сервер ошибся», а «результат больше не относится к текущему значению». Его не нужно превращать в ошибку, логировать как отказ или показывать человеку. Его надо молча отбросить как устаревший.

function startRemoteCheck(state, checkAvailability) {
  if (state.phase === 'client-invalid') return Promise.resolve(state);

  var checkedVersion = state.version;
  var checking = {
    value: state.value,
    version: checkedVersion,
    phase: 'checking',
    localError: '',
    remoteError: '',
  };

  return checkAvailability(checking.value).then(function (answer) {
    return {
      checkedVersion: checkedVersion,
      answer: answer,
    };
  });
}

function applyRemoteAnswer(current, result) {
  if (current.version !== result.checkedVersion) return current;

  return {
    value: current.value,
    version: current.version,
    phase: result.answer.available ? 'ready' : 'remote-invalid',
    localError: '',
    remoteError: result.answer.available ? '' : 'Этот логин уже занят',
  };
}

В рабочем коде current берётся из единственного владельца состояния в момент ответа, а не из замыкания старого рендера. Это различие важно: замыкание может держать объект первой версии, и его сравнение само с собой всегда даст «актуально». Хранилище, экземпляр компонента или reducer должен дать актуальное состояние. Если архитектура уже использует action-ы, полезно передавать checkedVersion прямо в action REMOTE_CHECK_RESOLVED.

Ниже минимальный fixture. Он намеренно задаёт задержки вручную: проверка ivan отвечает через 30 мс и считает логин занятым, проверка ivanka отвечает через 5 мс и считает его свободным. Это выполняемая модель порядка ответов, а не запись Network или тест настоящего браузера. Правильный результат: первый ответ отмечен как устаревший, а финальное состояние содержит ivanka и фазу valid.

// Запускается автономно командой:
// node web/scripts/upgrade-2019-04.mjs --run-fixture
// Ожидаемая форма результата:
{
  "results": [
    { "requestId": 1, "applied": false, "ignored": "stale-response" },
    { "requestId": 2, "applied": true, "phase": "valid" }
  ],
  "finalState": {
    "value": "ivanka",
    "requestId": 2,
    "phase": "valid",
    "fieldError": ""
  }
}

Fixture проверяет именно правило версии. Он не доказывает поддержку конкретного браузера, не измеряет задержку API и не проверяет доступность разметки. В реальном проекте рядом нужны отдельный тест клиента с настоящим адаптером API и ручная проверка семантики поля. Разделение доказательств важно: хороший результат promise не говорит ничего о том, услышит ли ошибку пользователь со скринридером.

Диаграмма конечного автомата поля формы: editing переходит в client-invalid или checking, ответ текущей версии приводит к ready или remote-invalid, а любой новый input очищает старый результат
Версия привязана к input: стрелка старого ответа обрывается до изменения состояния, если пользователь уже ввёл новое значение.

Где в автомате живёт HTML-валидация

Нативный Constraint Validation API не обязан быть конкурентом состоянию приложения. Его можно использовать на границе input и submit. Например, input.validity.valid быстро показывает, проходит ли контрол объявленные атрибуты; setCustomValidity() позволяет добавить локальный текст. Но если сервер вернул занятость логина, не подменяйте этим факт HTML-ограничение навсегда. Серверная ошибка относится к версии данных и должна исчезнуть на следующем input, а не жить как искусственный patternMismatch.

На submit форма собирает состояния полей. Если хоть одно поле находится в client-invalid, отправка не начинается: показываем ошибки и фокусируем первое проблемное поле. Если есть checking, команда должна выбрать правило явно: дождаться, отключить submit на короткое время или повторить серверную проверку в запросе сохранения. Нельзя назвать форму ready только потому, что локальная регулярка прошла, пока ответ проверки ещё не завершился.

Решения для submit во время remote-check
ПолитикаКогда подходитПлюсЦена и обязательная проверка
Ждать текущую проверкуКороткий запрос и одно полеМеньше дублей запросовПоказать доступное «Проверяем…»; убедиться, что интерфейс не завис при ошибке сети
Отправлять и проверять на сервереСервер всё равно проверяет условие атомарноОдин окончательный ответ для сохраненияКлиент не обещает готовность раньше ответа; сервер возвращает field error
Отключать кнопку до завершенияПроверка быстрая и смысл кнопки ясенПростой путь без гонки submitКнопка не должна быть единственным носителем объяснения; поле всё ещё доступно для правки

Доступность — тоже переход состояния

Для человека, который видит цвет, remote-invalid — это рамка и текст. Для другого пользователя это должно стать доступным состоянием поля. В разметке у input остаётся label; вспомогательный текст и ошибка имеют устойчивые ID; при ошибке есть aria-invalid="true" и ссылка на сообщение. aria-describedby описывает контрол, а aria-errormessage указывает на текст ошибки при невалидном состоянии. Это не повод полностью заменить нативный HTML ARIA-атрибутами: сначала используем нативный input и label.

Ниже приведено ожидаемое дерево, которое следует сверить в инструментах доступности после интеграции. Это не утверждение, что оно было снято в конкретном браузере. Разные движки и скринридеры по-разному представляют детали, поэтому проверяется не буквальный порядок строк, а инварианты: поле имеет имя, получает invalid при ошибке и связано с видимым описанием.

Инварианты ожидаемого Accessibility tree для фазы remote-invalid
ИнвариантРазметкаНаблюдаемый смыслНе является доказательством
У поля есть имя<label for>Пользователь понимает, что правит логинОдинаковое слово в каждом screen reader
Поле отмечено invalidaria-invalid="true"Ошибка относится к текущему контролуКачество текста ошибки
Ошибка достижимаaria-errormessage и видимый элемент«Этот логин уже занят» не спрятан в цветеАвтоматическое озвучивание в любой паре браузер/скринридер
Подсказка не исчезлаaria-describedbyПравило ввода остаётся доступно рядом с ошибкойКорректность серверного условия

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

  1. Для каждого поля назвать локальное условие, удалённое условие и текст, который видит пользователь. Если условия нет, не создаём искусственный async-check.
  2. Описать фазы и запретить невалидные комбинации: старый remoteError не живёт после input, а checking не означает ready.
  3. Добавить version в действие input и сохранять его при старте запроса. В обработчике ответа сравнить версию с текущим состоянием до любого render.
  4. Запустить fixture с двумя задержками в обратном порядке. Зафиксировать ожидаемое finalState, а не только отсутствие необработанного promise.
  5. Привязать ошибку к input через label, описание, aria-invalid и видимый контейнер сообщения. Проверить клавиатуру и дерево доступности на реальном контуре отдельно.
  6. Прогнать submit при client-invalid, checking, remote-invalid и готовом состоянии. Серверный ответ остаётся окончательным решением для сохранения.

Ограничения модели

  • Номер версии защищает только владельца UI-состояния. Он не отменяет запись на сервере и не заменяет идемпотентность операции сохранения.
  • AbortController можно добавить для экономии ресурсов, если используемый транспорт принимает signal. Даже после abort сравнение версии остаётся обязательным.
  • Проверка «логин свободен» до submit не гарантирует свободность во время сохранения: другой запрос может занять его между двумя операциями. Сервер должен проверять условие снова.
  • Фазы в статье описаны для одного поля. Сложная форма с зависимыми полями может хранить отдельный автомат формы и отдельные автоматы полей, но не должна прятать связь между ними.
  • ARIA-атрибуты не компенсируют отсутствие текста, label или правильного фокуса. Семантика проверяется вместе с интерфейсом, а не строковым поиском по HTML.

Итог

Форма становится предсказуемой не тогда, когда в ней появилось больше флагов, а когда у каждого ответа есть право на переход. Value меняет версию, локальная ошибка не вызывает сеть, async-ответ сравнивает свою версию, а серверная ошибка привязана к полю и доступной разметке. Такой автомат невелик, но он превращает «иногда приходит не та ошибка» в конкретную проверку: старый ответ не может изменить состояние нового значения.

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

  • HTML Standard: Constraints and Constraint Validation API — модель ограничений формы, validatable-контролы, validity, setCustomValidity(), checkValidity() и reportValidity()
  • HTML Standard: Form submission — отправка формы — отдельный алгоритм браузера; клиентские ограничения не подменяют проверку на сервере
  • WAI-ARIA 1.1: aria-describedby — связь контрола с одним или несколькими элементами описания по ID
  • WAI-ARIA 1.1: aria-errormessagearia-errormessage связан с aria-invalid; актуальный текст ошибки должен быть доступен пользователю
  • WAI-ARIA 1.1: alert role — семантика срочного, но не переносящего фокус сообщения; применять только к краткому изменению статуса
  • DOM Standard: AbortController — контроллер создаёт AbortSignal и посылает ему abort; это отмена транспорта, а не проверка актуальности состояния сама по себе
  • Fetch Standard — модель запроса, ответа и интеграция с abort signal для клиентов, поддерживающих Fetch