Валидация форм на фронтенде: как показывать ошибки правильно
Constraint Validation API даёт браузерную валидацию из коробки, но её стандартный вид отпугивает пользователей. Разбираем рабочие приёмы валидации и подачи ошибок так, чтобы форму дозаполняли, а не бросали.

Пользователь заполнил форму регистрации, нажал «Отправить» — и получил красную рамку без единого слова о том, что не так. Или, наоборот, поле стало красным на второй набранной букве, когда он ещё не дописал email. Оба сценария заканчиваются одинаково: форму бросают. Валидация на фронтенде — это не только проверка данных, это момент, где UX ошибок решает, дойдёт ли человек до кнопки.
Речь пойдёт о клиентской валидации в браузере: нативном Constraint Validation API, кастомных сообщениях, моменте показа ошибки и доступности. Серверная валидация — отдельная тема, здесь она упоминается только там, где без неё нельзя. Примеры на ванильном JS, работают в актуальных браузерах на движках Chromium, Gecko и WebKit по состоянию на 2024 год.
Проблема очевидного решения
Самый частый подход новичка — навесить required и type="email" и положиться на браузер. Работает, но два раза бьёт по UX. Первое: стандартный пузырёк-подсказка (native validation bubble) выглядит по-разному в каждом браузере, его нельзя стилизовать и он исчезает при клике. Второе: он показывается только по событию submit и только для первого невалидного поля — пользователь исправляет одно, жмёт снова, видит следующую ошибку, и так по кругу.
Второй по частоте подход — валидировать всё на каждое нажатие клавиши (input). Тогда поле для email краснеет, пока человек ещё печатает первую половину адреса. Ошибка технически верна, но психологически это упрёк за незаконченное действие.
Правило момента: не ругай пользователя за то, что он ещё делает. Проверяй поле при потере фокуса (blur) или при отправке, а после первой ошибки — переходи на проверку поinput, чтобы человек видел, как ошибка исчезает по мере исправления.
Constraint Validation API вместо ручных проверок
Браузер уже умеет проверять базовые правила: обязательность, формат email, длину, соответствие шаблону pattern. Не нужно писать регулярку для email вручную — достаточно правильно прочитать результат встроенной проверки.
У каждого элемента формы есть свойство validity — объект ValidityState. Он говорит не просто «поле невалидно», а почему именно:
valueMissing— обязательное поле пустое;typeMismatch— значение не подходит подtype(email, url);tooShort/tooLong— нарушеныminlength/maxlength;rangeUnderflow/rangeOverflow— за пределамиmin/max;patternMismatch— не совпало сpattern;customError— вы сами установили ошибку черезsetCustomValidity.
Метод element.checkValidity() возвращает true/false и заодно генерирует событие invalid на невалидных полях. Метод reportValidity() делает то же, но ещё показывает нативный пузырёк — его как раз стоит избегать, если рисуете свои сообщения.
Свои сообщения вместо браузерных
Ключевой приём — отключить нативный пузырёк и рисовать сообщение самому. Отключение делается атрибутом novalidate на форме: он убирает всплывающие подсказки и авто-блокировку отправки, но оставляет доступным весь Constraint Validation API. То есть validity и checkValidity() продолжают работать — вы просто берёте управление показом на себя.
<!-- novalidate отключает нативные пузырьки, но не саму валидацию -->
<form id="signup" novalidate>
<label for="email">Email</label>
<input id="email" name="email" type="email" required
aria-describedby="email-error">
<span class="error" id="email-error" role="alert" hidden></span>
<label for="pwd">Пароль</label>
<input id="pwd" name="pwd" type="password" required minlength="8"
aria-describedby="pwd-error">
<span class="error" id="pwd-error" role="alert" hidden></span>
<button type="submit">Зарегистрироваться</button>
</form>Теперь JS. Функция переводит машинный validity в человеческое сообщение и управляет моментом показа:
const form = document.getElementById('signup');
// Понятные сообщения вместо «Please fill out this field»
function messageFor(input) {
const v = input.validity;
if (v.valueMissing) return 'Заполните это поле';
if (v.typeMismatch && input.type === 'email')
return 'Введите корректный email, например name@mail.ru';
if (v.tooShort)
return `Минимум ${input.minLength} символов, сейчас ${input.value.length}`;
return 'Проверьте значение';
}
// Показать или скрыть ошибку под конкретным полем
function updateField(input) {
const box = document.getElementById(input.id + '-error');
if (input.validity.valid) {
box.hidden = true;
box.textContent = '';
input.removeAttribute('aria-invalid');
} else {
box.textContent = messageFor(input);
box.hidden = false;
input.setAttribute('aria-invalid', 'true');
}
}
// Первая проверка — при потере фокуса, а не на каждой букве
form.querySelectorAll('input').forEach((input) => {
input.addEventListener('blur', () => updateField(input));
// После первой ошибки переходим на проверку в реальном времени
input.addEventListener('input', () => {
if (input.getAttribute('aria-invalid') === 'true') updateField(input);
});
});
form.addEventListener('submit', (e) => {
let firstInvalid = null;
form.querySelectorAll('input').forEach((input) => {
updateField(input);
if (!input.validity.valid && !firstInvalid) firstInvalid = input;
});
if (firstInvalid) {
e.preventDefault();
firstInvalid.focus(); // курсор на первое проблемное поле
}
});Здесь три вещи работают на UX сразу: сообщение появляется под полем и остаётся видимым; после ошибки поле проверяется на лету, и пользователь видит, как красное становится обычным; при отправке фокус прыгает на первую проблему, а не оставляет человека гадать.
Кастомные правила: setCustomValidity
Правила вроде «пароли должны совпадать» браузер не проверит — таких ограничений в HTML нет. Для них есть setCustomValidity(message). Если передать непустую строку, поле становится невалидным (customError = true), и оно попадает в общую логику. Важный нюанс: чтобы снять ошибку, нужно вызвать setCustomValidity('') с пустой строкой — иначе поле останется «сломанным» навсегда.
const pwd = document.getElementById('pwd');
const repeat = document.getElementById('pwd-repeat');
function checkMatch() {
if (repeat.value !== pwd.value) {
repeat.setCustomValidity('Пароли не совпадают');
} else {
repeat.setCustomValidity(''); // обязательно сбросить
}
updateField(repeat);
}
repeat.addEventListener('input', checkMatch);
pwd.addEventListener('input', checkMatch);Доступность: ошибку должен услышать скринридер
Красная рамка ничего не значит для незрячего пользователя. Три атрибута закрывают минимум:
aria-invalid="true"на невалидном поле — скринридер объявит поле ошибочным;aria-describedbyсвязывает поле с элементом-сообщением поid, чтобы текст ошибки прочитался вместе с меткой поля;role="alert"на контейнере сообщения заставляет вспомогательные технологии озвучить текст сразу, как он появился.
Не полагайтесь только на цвет: около 8% мужчин имеют нарушения цветовосприятия, для них красная рамка неотличима от обычной. Добавьте иконку или текст.
Когда какой подход
| Задача | Инструмент | Чем платите |
|---|---|---|
| Обязательность, формат email, длина | HTML-атрибуты + validity | Ничего, работает из коробки |
| Свой вид и текст ошибок | novalidate + свой рендер | Пишете логику показа сами |
| Правила между полями (совпадение, зависимость) | setCustomValidity | Нужно вручную сбрасывать ошибку |
| Уникальность email, проверка на бэке | Асинхронный запрос + серверная проверка | Задержка, состояние загрузки, дубль на сервере |
Что ломается на практике
- Забыли сбросить setCustomValidity. Поле остаётся невалидным даже после исправления. Всегда парой: установили — при исправлении очистили пустой строкой.
- Валидация только на фронте. Клиентская проверка — это UX, а не безопасность. Любой запрос обходит её через консоль или curl. Сервер обязан проверять те же правила заново.
- Красная рамка на пустом поле при загрузке. Если стилизуете через CSS-псевдокласс
:invalid, пустое обязательное поле красное сразу. Комбинируйте с:user-invalid(поддержка в актуальных браузерах) или подключайте стиль по атрибутуaria-invalid, который вы ставите сами после первого взаимодействия. - Автозаполнение браузера. При вставке значения из менеджера паролей событие
inputможет не сработать так, как ожидаете. Проверяйте состояние ещё и наsubmit.
Что делать дальше
Соберите базовый вариант на нативном API — он покрывает большинство форм без библиотек. Если форм много и правила повторяются, посмотрите в сторону схемной валидации (Zod, Yup) в связке с фреймворком, но и там принцип момента показа остаётся тем же. Обязательно продублируйте правила на сервере и прогоните форму через скринридер (VoiceOver на macOS, NVDA на Windows) — это единственный способ убедиться, что ошибку не только видно, но и слышно.
Источники: Constraint Validation API — MDN Web Docs, ValidityState — MDN Web Docs
Частые вопросы
Атрибут novalidate полностью отключает валидацию?
А если у меня форма во фреймворке — React или Vue?
Если пользователь отключил JavaScript, валидация отвалится?
Почему не сработало сбрасывание ошибки после setCustomValidity?
Стоит ли валидировать на каждое нажатие клавиши?
Как связать сообщение об ошибке со скринридером?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.