Симптом: у формы есть 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 | Текущее значение, без окончательного вердикта | Значение и его version | input → локальная проверка |
client-invalid | Ошибка формата или обязательности | localError; сетевой запрос не нужен | input → editing или проверка |
checking | Короткое «Проверяем…», поле всё ещё можно менять | version запроса и очищенная старая remoteError | ответ той же версии → ready или remote-invalid |
remote-invalid | Серверный текст у поля | remoteError только для текущего value | input → 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 не говорит ничего о том, услышит ли ошибку пользователь со скринридером.
Где в автомате живёт HTML-валидация
Нативный Constraint Validation API не обязан быть конкурентом состоянию приложения. Его можно использовать на границе input и submit. Например, input.validity.valid быстро показывает, проходит ли контрол объявленные атрибуты; setCustomValidity() позволяет добавить локальный текст. Но если сервер вернул занятость логина, не подменяйте этим факт HTML-ограничение навсегда. Серверная ошибка относится к версии данных и должна исчезнуть на следующем input, а не жить как искусственный patternMismatch.
На submit форма собирает состояния полей. Если хоть одно поле находится в client-invalid, отправка не начинается: показываем ошибки и фокусируем первое проблемное поле. Если есть checking, команда должна выбрать правило явно: дождаться, отключить submit на короткое время или повторить серверную проверку в запросе сохранения. Нельзя назвать форму ready только потому, что локальная регулярка прошла, пока ответ проверки ещё не завершился.
| Политика | Когда подходит | Плюс | Цена и обязательная проверка |
|---|---|---|---|
| Ждать текущую проверку | Короткий запрос и одно поле | Меньше дублей запросов | Показать доступное «Проверяем…»; убедиться, что интерфейс не завис при ошибке сети |
| Отправлять и проверять на сервере | Сервер всё равно проверяет условие атомарно | Один окончательный ответ для сохранения | Клиент не обещает готовность раньше ответа; сервер возвращает field error |
| Отключать кнопку до завершения | Проверка быстрая и смысл кнопки ясен | Простой путь без гонки submit | Кнопка не должна быть единственным носителем объяснения; поле всё ещё доступно для правки |
Доступность — тоже переход состояния
Для человека, который видит цвет, remote-invalid — это рамка и текст. Для другого пользователя это должно стать доступным состоянием поля. В разметке у input остаётся label; вспомогательный текст и ошибка имеют устойчивые ID; при ошибке есть aria-invalid="true" и ссылка на сообщение. aria-describedby описывает контрол, а aria-errormessage указывает на текст ошибки при невалидном состоянии. Это не повод полностью заменить нативный HTML ARIA-атрибутами: сначала используем нативный input и label.
Ниже приведено ожидаемое дерево, которое следует сверить в инструментах доступности после интеграции. Это не утверждение, что оно было снято в конкретном браузере. Разные движки и скринридеры по-разному представляют детали, поэтому проверяется не буквальный порядок строк, а инварианты: поле имеет имя, получает invalid при ошибке и связано с видимым описанием.
| Инвариант | Разметка | Наблюдаемый смысл | Не является доказательством |
|---|---|---|---|
| У поля есть имя | <label for> | Пользователь понимает, что правит логин | Одинаковое слово в каждом screen reader |
| Поле отмечено invalid | aria-invalid="true" | Ошибка относится к текущему контролу | Качество текста ошибки |
| Ошибка достижима | aria-errormessage и видимый элемент | «Этот логин уже занят» не спрятан в цвете | Автоматическое озвучивание в любой паре браузер/скринридер |
| Подсказка не исчезла | aria-describedby | Правило ввода остаётся доступно рядом с ошибкой | Корректность серверного условия |
Порядок проектирования и проверки
- Для каждого поля назвать локальное условие, удалённое условие и текст, который видит пользователь. Если условия нет, не создаём искусственный async-check.
- Описать фазы и запретить невалидные комбинации: старый remoteError не живёт после input, а checking не означает ready.
- Добавить version в действие input и сохранять его при старте запроса. В обработчике ответа сравнить версию с текущим состоянием до любого render.
- Запустить fixture с двумя задержками в обратном порядке. Зафиксировать ожидаемое finalState, а не только отсутствие необработанного promise.
- Привязать ошибку к input через label, описание,
aria-invalidи видимый контейнер сообщения. Проверить клавиатуру и дерево доступности на реальном контуре отдельно. - Прогнать 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-errormessage —
aria-errormessageсвязан сaria-invalid; актуальный текст ошибки должен быть доступен пользователю - WAI-ARIA 1.1: alert role — семантика срочного, но не переносящего фокус сообщения; применять только к краткому изменению статуса
- DOM Standard: AbortController — контроллер создаёт
AbortSignalи посылает ему abort; это отмена транспорта, а не проверка актуальности состояния сама по себе - Fetch Standard — модель запроса, ответа и интеграция с abort signal для клиентов, поддерживающих Fetch