React: формы, ошибки ввода и доступность
Оглавление · События и состояние · Валидация
Задача: принять учебный заголовок события, показать ошибку возле поля и вернуть пользователя к исправлению. Нужны управляемое состояние и события. Форма ниже работает только с локальными данными: она не создаёт запись CMS.
Черновик и принятое значение
Поле содержит черновик: пользователь может ещё вводить пробелы или слишком длинную строку. Модель приложения получает значение после разбора. Сразу запрещать каждый промежуточный ввод не всегда удобно — черновик и допустимое значение имеют разные множества состояний.
Опишем parse: String → Accepted(Title) + Rejected(Message):
"use client";
import { useId, useRef, useState, type FormEvent } from "react";
type TitleResult = { success: true; title: string } | { success: false; message: string };
export function parseTitle(raw: string): TitleResult {
const title = raw.trim();
if (title.length === 0) return { success: false, message: "Введите заголовок." };
if (title.length > 120)
return { success: false, message: "Сократите заголовок до 120 единиц длины." };
return { success: true, title };
}
export function EventDraftForm() {
const [rawTitle, setRawTitle] = useState("");
const [error, setError] = useState<string | null>(null);
const [acceptedTitle, setAcceptedTitle] = useState<string | null>(null);
const id = useId();
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
const inputRef = useRef<HTMLInputElement>(null);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const parsed = parseTitle(rawTitle);
if (!parsed.success) {
setError(parsed.message);
setAcceptedTitle(null);
inputRef.current?.focus();
return;
}
setError(null);
setAcceptedTitle(parsed.title);
}
return (
<form onSubmit={submit} noValidate>
<label htmlFor={id}>Заголовок события (обязательно)</label>
<p id={hintId}>От 1 до 120 единиц длины после удаления крайних пробелов.</p>
<input
ref={inputRef}
id={id}
name="title"
required
value={rawTitle}
aria-invalid={error !== null}
aria-describedby={error ? `${hintId} ${errorId}` : hintId}
onChange={(event) => {
setRawTitle(event.target.value);
setError(null);
setAcceptedTitle(null);
}}
/>
{error && (
<p id={errorId} role="alert">
{error}
</p>
)}
<button type="submit">Проверить заголовок</button>
<p role="status">{acceptedTitle ? `Учебный заголовок принят: ${acceptedTitle}` : ""}</p>
</form>
);
}
Это полный модуль. noValidate отключает встроенную блокировку отправки браузером,
чтобы мы могли показать одну явную политику ошибки. required по-прежнему
описывает обязательность поля. Без noValidate браузерная проверка могла бы
перехватить пустое поле до обработчика формы.
Длина здесь — JS string.length, число кодовых единиц UTF-16, а не визуальных
букв: у некоторых emoji она равна двум. Для реального редакционного ограничения
нужно выбрать меру и пользовательскую формулировку, затем одинаково применить
её на клиенте и сервере. Учебный пример оставляет техническую меру явной.
Отправка — событие формы
onSubmit относится к форме, поэтому не зависит только от клика мышью.
Кнопка имеет type="submit"; отдельная кнопка сброса или открытия справки
должна иметь type="button", если не должна отправлять форму.
preventDefault() останавливает стандартную отправку, а не отменяет весь
пользовательский ввод.
Контролируемое поле получает строковый value и синхронно обновляет её в
onChange. Нельзя начать с undefined, а затем переключиться на строку:
это меняет режим поля. Управляемые input.
Нажатие «Проверить» — конкретное действие, поэтому разбор выполняется в
обработчике. Эффект, наблюдающий за acceptedTitle и отправляющий запись,
создал бы неявную связь между рендером и бизнес-действием.
Когда эффект не нужен.
Исходник схемы
flowchart LR Draft["Черновик поля"] --> Submit["Событие submit"] Submit --> Parse["Разбор заголовка"] Parse --> Error["Ошибка у поля и фокус"] Parse --> Good["Принятое нормализованное значение"]
Стрелки показывают значения и исходы одного действия. Изменение поля снова делает его черновиком: в примере очищается прежнее сообщение об успехе. Иначе можно было бы показывать «принято» рядом с уже изменённым текстом.
Ошибка должна быть связана с полем
Label даёт полю имя; placeholder его не заменяет. Hint описывает ограничение,
aria-describedby связывает подсказку и, при ошибке, её текст с полем.
aria-invalid сообщает о неверном вводе. role="alert" объявляет появившуюся
ошибку, role="status" — менее срочное подтверждение.
Сообщения формы WAI.
useRef сохраняет ссылку на DOM-поле между рендерами. В обработчике отказа
вызывается focus(). Изменение ref.current само по себе не вызывает рендер;
показываемые значения хранятся в state. В форме с несколькими полями нужны
переход к первой ошибке и понятное общее сообщение, если ошибки распределены.
Не полагайтесь только на цвет рамки. Текст сообщает, что исправить; управление клавиатурой должно позволять добраться до поля и отправить форму. Атрибуты в DOM полезны, но полноценная проверка требует браузера и вспомогательных технологий.
Где проходит серверная граница
Клиентская проверка помогает исправить ввод; отправитель может её обойти. Сервер снова разбирает payload и отдельно проверяет права и бизнес-инварианты. В Atmanki webhook делает свой разбор независимо от интерфейса; редакторский контент создаётся в Strapi. Общие Zod-контракты остаются источником схем для соответствующего API.
parseTitle выше — отдельное упражнение, не новая схема production. Для
рабочих примитивов проекта используйте Mantine напрямую; нативная форма здесь
показывает связь событий, HTML и React без дополнительной зависимости docs.
Если добавлять сохранение на сервер, понадобятся состояния отправки и отказа, правило повторов и поведение при потерянном ответе. Заблокированная кнопка не является гарантией однократной записи. Эти вопросы относятся к API-контракту.
Практика и самопроверка
Сохраните модуль как временный apps/docs/src/react-form.tsx, выполните
pnpm --filter @atmanki/docs typecheck. Временная страница
apps/docs/src/app/lesson/page.tsx может импортировать EventDraftForm
из @/react-form и вернуть <EventDraftForm />.
Запустите pnpm docs:dev и откройте /lesson/.
Проверьте пробелы, корректный заголовок с крайними пробелами, 120 и 121 букву a.
После отказа поле получает фокус и связанную ошибку. После успешного разбора
показывается нормализованный заголовок; изменение поля убирает подтверждение.
Проверьте отправку клавиатурой и две формы на одной странице: ID не совпадают.
Затем добавьте необязательное поле описания. Решите, относится ли его пустая строка к допустимой модели и как сообщать об ошибке именно этого поля. Не меняйте действующий webhook ради упражнения. Удалите временные файлы после работы.