Учебник веб-разработки
Разделы учебника
На этой странице

IV. React и состояние

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 ради упражнения. Удалите временные файлы после работы.