К списку уроков

Урок 13. Формы: управляемые поля, проверка данных и react-hook-form

Онлайн и в Б-734, 9 ноября (понедельник), 15:00

Предисловие

Для вашего удобства текстовый курс перенесен на отдельную платформу. Ссылка для поступления: тык

Мы настоятельно рекомендуем пользоваться именно этой платформой по ходу курса. Ссылка на текущее занятие: тык

⚠️Прежде чем переходить к заданиям - обязательно зарегистрируйтесь на курс. Иначе вас закинет на общий поток и вы не сможете взаимодействовать с сверстниками во втором модуле курса. Это не смертельно и вполне решаемо, но тем не менее.

Формы: управляемые поля, проверка данных и react-hook-form

Как поля формы работают в React

В обычном HTML поле ввода само хранит своё значение. Пользователь печатает, браузер меняет значение поля, а скрипт читает его, когда нужно, например при отправке формы через form.elements или FormData.

В React есть два способа работать с полями.

  • Неуправляемое поле хранит значение само, как в обычном HTML. React не знает, что введено, пока код не прочитает значение из элемента.

  • Управляемое поле получает значение из состояния React. Когда пользователь печатает, поле сообщает об изменении, код записывает новое значение в состояние, и React перерисовывает поле с этим значением.

У управляемого поля значение всегда совпадает с состоянием. Поэтому интерфейс может реагировать на ввод сразу: показать число оставшихся символов, выключить кнопку при пустом поле, показать картинку по введённой ссылке, отфильтровать список при вводе поискового запроса.

У неуправляемого поля меньше кода, но код узнаёт значение только в момент, когда сам его прочитает.

Простые формы из одного-двух полей удобно делать управляемыми. Для больших форм с проверкой данных используют библиотеку react-hook-form: она берёт на себя хранение значений, проверку и ошибки. Оба способа разберём на этом занятии.

Почитать ещё

  • React: <input>

Управляемое поле

Управляемое поле связывают с состоянием двумя пропсами: value и onChange.

import { useState } from 'react';

export function SearchField() {
  const [query, setQuery] = useState('');

  return (
    <label>
      Поиск по карточкам
      <input type="search" value={query} onChange={(event) => setQuery(event.target.value)} />
      {query && <span>Ищем «{query}»</span>}
    </label>
  );
}
  • value={query} — поле показывает значение из состояния.

  • onChange вызывается при каждом изменении значения, то есть после каждого введённого или удалённого символа. В отличие от события change в браузере, onChange в React работает как событие input.

  • event.target.value — новое значение поля, всегда строка.

Если передать value без onChange, поле станет доступным только для чтения: React будет возвращать ему значение из состояния после каждого нажатия клавиши. В консоли появится предупреждение об этом.

Остальные поля подключают так же:

const [description, setDescription] = useState('');
const [type, setType] = useState<CardType>('idea');
const [onlyMine, setOnlyMine] = useState(false);

<textarea value={description} onChange={(event) => setDescription(event.target.value)} />

<select value={type} onChange={(event) => setType(event.target.value as CardType)}>
  <option value="event">Событие</option>
  <option value="idea">Идея</option>
  <option value="question">Вопрос</option>
</select>

<input type="checkbox" checked={onlyMine} onChange={(event) => setOnlyMine(event.target.checked)} />
  • У <textarea> значение передают пропсом value, а не текстом между тегами.

  • У <select> выбранный вариант задают пропсом value у самого <select>, а не атрибутом selected у <option>. Значение приходит строкой, поэтому для литерального типа нужно приведение as CardType: варианты в списке совпадают с типом, но TypeScript не может это проверить.

  • У флажка значение хранится в checked, а в обработчике читается event.target.checked.

Почитать ещё

  • React: <input>, <select>, <textarea>

Отправка формы

Форму отправляют обработчиком onSubmit на элементе <form>. Так форма отправится и кнопкой, и нажатием Enter в поле.

Форма комментария в ITAM Board:

import { useState, type SubmitEvent } from 'react';
import { addComment } from '@/entities/comment';
import { getErrorMessage, type Comment } from '@/shared/api';
import { Button } from '@/shared/ui';

type Props = {
  cardId: string;
  onAdded: (comment: Comment) => void;
};

export function AddCommentForm({ cardId, onAdded }: Props) {
  const [text, setText] = useState('');
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    setError(null);
    try {
      const comment = await addComment(cardId, text.trim());
      onAdded(comment);
      setText('');
    } catch (err) {
      setError(getErrorMessage(err));
    } finally {
      setPending(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <textarea
        value={text}
        onChange={(event) => setText(event.target.value)}
        aria-label="Комментарий"
        maxLength={2000}
        rows={3}
      />
      {error && <p className="error">{error}</p>}
      <Button type="submit" variant="primary" disabled={pending || !text.trim()}>
        {pending ? 'Отправляем…' : 'Отправить'}
      </Button>
    </form>
  );
}

Порядок действий при отправке тот же, что на седьмом занятии:

  1. event.preventDefault() отменяет отправку формы браузером и перезагрузку страницы.

  2. Перед запросом включается pending, старая ошибка сбрасывается. Кнопка выключена, пока идёт запрос.

  3. Запрос выполняется в try, ошибка записывается в состояние в catch, pending выключается в finally.

  4. После успешного ответа родитель узнаёт о новом комментарии через колбэк onAdded, а поле очищается одной строкой setText('').

Поле управляемое, поэтому кнопка выключена, пока текст пустой, а после отправки поле очищается простой записью в состояние.

Почитать ещё

  • React: <form>

Проверка данных на клиенте и на сервере

Данные формы проверяют в двух местах, и у каждой проверки своя задача.

Проверка на клиенте нужна для удобства. Пользователь сразу видит, что забыл заполнить заголовок, и не ждёт ответа сервера. Такая проверка работает мгновенно и не отправляет лишних запросов.

Проверка на сервере нужна для надёжности. Клиентской проверке доверять нельзя: пользователь может отключить её в инструментах разработчика или отправить запрос в обход интерфейса, например через Swagger. Кроме того, только сервер может проверить то, что зависит от данных: занята ли почта, существует ли карточка, принадлежит ли она пользователю.

Правила проверки на клиенте берут из документации API. В описании схемы CardCreate указано, что заголовок обязателен и не длиннее 120 символов. Если клиент проверит эти правила сам, пользователь узнает об ошибке ещё до отправки.

Клиентская и серверная проверки иногда расходятся. Например, заголовок из одних пробелов пройдёт клиентскую проверку «поле заполнено», а сервер уберёт пробелы по краям и ответит, что заголовок пустой. Это нормально. Часть таких случаев закрывают на клиенте, например вызывают trim() перед отправкой, но все случаи предусмотреть невозможно. Важно, чтобы ошибка сервера выглядела для пользователя так же, как ошибка клиентской проверки: сообщение под нужным полем.

Почитать ещё

Сообщения об ошибках в форме

Хорошая форма помогает пользователю исправить ошибку. Несколько правил:

  • Сообщение стоит рядом с полем. Пользователь видит, какое поле исправить, без поиска. Общие ошибки, которые не относятся к конкретному полю, например «Сервер не отвечает», показывают над кнопками отправки.

  • Сообщение объясняет, что сделать. «Введите заголовок» и «Не длиннее 120 символов» полезнее, чем «Ошибка» или «Invalid value».

  • Поле с ошибкой выделено, например красной рамкой, чтобы его было видно в длинной форме.

  • Кнопку отправки не выключают из-за ошибок в полях. Если кнопка неактивна, пользователь не понимает почему. Лучше дать нажать и показать все ошибки сразу. Выключать кнопку имеет смысл только во время отправки.

  • Введённые данные не пропадают после ошибки. Если сервер ответил ошибкой, поля остаются заполненными.

Встроенная проверка браузера из третьего занятия плохо сочетается с собственными сообщениями. Браузер показывает свои всплывающие подсказки поверх интерфейса, их внешний вид и текст нельзя изменить. Поэтому в формах с проверкой на JavaScript элементу <form> добавляют атрибут noValidate, а правила описывают в коде:

<form onSubmit={handleSubmit} noValidate>
  <input type="email" />
</form>;

С noValidate атрибуты required и type="email" у полей можно оставить: браузер не будет проверять форму, но на телефоне для поля почты по-прежнему откроется удобная клавиатура.

Почитать ещё

  • Дока: novalidate

react-hook-form

Для формы карточки понадобится пять полей, правила проверки для нескольких из них, сообщения об ошибках, состояние отправки и ошибки сервера. С управляемыми полями это пять состояний, пять обработчиков onChange, функция проверки и ещё несколько состояний для ошибок.

Библиотека react-hook-form берёт эту работу на себя. Вы описываете поля и правила, а библиотека хранит значения, проверяет их при отправке, собирает ошибки и следит за состоянием отправки.

npm install react-hook-form
import { useForm } from 'react-hook-form';

type FormValues = {
  title: string;
  description: string;
};

export function SimpleCardForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({ defaultValues: { title: '', description: '' } });

  async function submit(values: FormValues) {
    console.log(values.title, values.description);
  }

  return (
    <form onSubmit={handleSubmit(submit)} noValidate>
      <input {...register('title', { required: 'Введите заголовок' })} />
      {errors.title && <p className="error">{errors.title.message}</p>}

      <textarea {...register('description')} rows={4} />

      <button type="submit" disabled={isSubmitting}>
        Создать
      </button>
    </form>
  );
}
  • useForm<FormValues> создаёт форму. Тип FormValues описывает значения полей: TypeScript проверит имена полей в register и в errors.

  • defaultValues задаёт начальные значения.

  • register('title', правила) подключает поле к форме.

  • handleSubmit(submit) превращается в обработчик отправки: проверяет правила и, если ошибок нет, вызывает submit со значениями формы.

  • formState.errors содержит ошибки по полям, а formState.isSubmitting равен true, пока выполняется submit.

По умолчанию react-hook-form не делает поля управляемыми: значения хранятся в самих полях, и библиотека читает их через ref. Благодаря этому ввод текста не перерисовывает всю форму.

Почитать ещё

register и правила проверки

Функция register возвращает объект с пропсами name, onChange, onBlur и ref. Запись {...register('title')} раскладывает их на поле. Через эти пропсы библиотека узнаёт об изменениях и получает доступ к элементу.

Второй аргумент register — правила проверки:

<input
  {...register('title', {
    required: 'Введите заголовок',
    maxLength: { value: 120, message: 'Не длиннее 120 символов' },
  })}
/>

<input
  type="email"
  {...register('email', {
    pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Похоже, это не адрес почты' },
  })}
/>

<input
  {...register('telegram', {
    pattern: { value: /^@?[A-Za-z0-9_]{5,32}$/, message: 'От 5 до 32 латинских букв, цифр или _' },
  })}
/>

<input
  {...register('status', {
    validate: (value) => value.trim() !== 'admin' || 'Этот статус занят',
  })}
/>

Правило

Что проверяет

required

Поле заполнено

minLength, maxLength

Длина строки

min, max

Границы числа

pattern

Значение подходит под регулярное выражение

validate

Произвольная функция: возвращает true, если значение подходит, или текст ошибки

Правило записывают объектом { value, message } или, для required, сразу строкой сообщения. Текст из message попадёт в errors.имяПоля.message.

Регулярное выражение в правиле pattern — шаблон строки. Выражение /^@?[A-Za-z0-9_]{5,32}$/ читается так: от начала строки ^ может стоять @, затем от 5 до 32 символов из латинских букв, цифр и подчёркивания, и дальше конец строки $. Регулярные выражения — отдельная большая тема. На первое время достаточно понимать готовые выражения из документации API или справочника.

По умолчанию проверка запускается при отправке формы. После первой неудачной отправки поля с ошибками перепроверяются при каждом изменении: как только пользователь исправит значение, сообщение пропадёт. Это поведение можно изменить опцией mode в useForm.

Почитать ещё

Отправка и состояние формы

Функция отправки получает значения формы уже после проверки правил. Внутри неё отправляют запрос.

const {
  register,
  handleSubmit,
  formState: { errors, isSubmitting },
} = useForm<FormValues>({ defaultValues: toFormValues(card) });

async function submit(values: FormValues) {
  const saved = card ? await updateCard(card.id, toRequestBody(values)) : await createCard(toRequestBody(values));
  onDone(saved);
}

return (
  <form onSubmit={handleSubmit(submit)} noValidate>
    {/* поля */}
    <Button type="submit" variant="primary" disabled={isSubmitting}>
      {isSubmitting ? 'Сохраняем…' : 'Сохранить'}
    </Button>
  </form>
);
  • handleSubmit сам вызывает event.preventDefault(), поэтому в функции отправки его писать не нужно.

  • Если функция отправки асинхронная, isSubmitting остаётся true, пока промис не завершится. Отдельное состояние pending не нужно.

  • Если хотя бы одно правило не выполнено, функция отправки не вызывается, а ошибки появляются в errors. Фокус переходит на первое поле с ошибкой.

У formState есть и другие полезные свойства:

Свойство

Значение

errors

Ошибки по полям

isSubmitting

Форма отправляется

isDirty

Пользователь изменил хотя бы одно поле относительно начальных значений

isSubmitSuccessful

Последняя отправка завершилась без ошибок

Метод reset() из useForm возвращает поля к начальным значениям или к новым, если передать их аргументом. Он пригодится, если форма остаётся на экране после успешной отправки.

Почитать ещё

  • react-hook-form: handleSubmit, formState (англ.)

Значения формы и данные API

Значения полей формы и данные, которые принимает API, отличаются. В форме всё хранится строками: пустое описание — пустая строка, дата из поля datetime-local — строка вида '2026-10-10T19:00'. API ждёт null вместо пустых полей и Unix-время вместо строки с датой.

Поэтому у формы свой тип значений и две функции перевода на её границе:

type FormValues = {
  title: string;
  type: CardType;
  description: string;
  preview: string;
  date: string;
};

function toFormValues(card?: Card): FormValues {
  return {
    title: card?.title ?? '',
    type: card?.type ?? 'idea',
    description: card?.description ?? '',
    preview: card?.preview ?? '',
    date: card?.date ? toDateTimeLocal(card.date) : '',
  };
}

function toRequestBody(values: FormValues): CardCreate {
  return {
    title: values.title.trim(),
    type: values.type,
    description: values.description.trim() || null,
    preview: values.preview.trim() || null,
    date: values.date ? toUnix(values.date) : null,
  };
}
  • toFormValues превращает карточку в начальные значения формы. Если карточки нет, получаются значения для создания новой.

  • toRequestBody превращает значения формы в тело запроса: убирает пробелы по краям, заменяет пустые строки на null и переводит дату в Unix-время.

Функции для дат удобно держать в shared/lib:

// src/shared/lib/time.ts
export function toUnix(dateTimeLocal: string) {
  return Math.floor(new Date(dateTimeLocal).getTime() / 1000);
}

export function toDateTimeLocal(unix: number) {
  const date = new Date(unix * 1000);
  const pad = (value: number) => String(value).padStart(2, '0');
  const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
  return `${day}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}

Поле datetime-local работает с датой и временем в часовом поясе браузера без указания зоны. new Date('2026-10-10T19:00') понимает такую строку как местное время, поэтому перевод туда и обратно не сдвигает время.

Почитать ещё

  • MDN: <input type="datetime-local">

Ошибки сервера в форме

Если сервер отклонил данные, ошибки нужно показать так же, как ошибки клиентской проверки. Метод setError из useForm ставит ошибку полю вручную.

API курса на ошибки проверки отвечает статусом 422, а на занятую почту — 409, и в обоих случаях перечисляет ошибки полей в errors. Клиент API из прошлого занятия сохраняет их в ApiError.fieldErrors. Осталось разложить их по полям формы:

// src/shared/lib/form-errors.ts
import type { FieldValues, Path, UseFormSetError } from 'react-hook-form';
import { ApiError, getErrorMessage } from '@/shared/api';

export function showServerErrors<T extends FieldValues>(
  error: unknown,
  setError: UseFormSetError<T>,
  fields: readonly Path<T>[],
) {
  const fieldErrors = error instanceof ApiError ? error.fieldErrors : [];
  const known = fieldErrors.filter((item) => fields.includes(item.field as Path<T>));

  for (const item of known) {
    setError(item.field as Path<T>, { type: 'server', message: item.message });
  }
  if (known.length === 0) {
    setError('root.server', { type: 'server', message: getErrorMessage(error) });
  }
}
  • Функция обобщённая: она работает с формой любого типа T. Тип Path<T> из react-hook-form описывает имена полей этой формы.

  • Ошибки полей, которые есть в форме, встают под эти поля.

  • Если таких ошибок нет, например сервер недоступен или вернул 403, общее сообщение записывается в root.server.

В форме функцию вызывают в блоке catch:

const FIELDS = ['title', 'type', 'description', 'preview', 'date'] as const;

async function submit(values: FormValues) {
  try {
    const body = toRequestBody(values);
    const saved = card ? await updateCard(card.id, body) : await createCard(body);
    onDone(saved);
  } catch (err) {
    showServerErrors(err, setError, FIELDS);
  }
}

return (
  <form onSubmit={handleSubmit(submit)} noValidate>
    {/* поля */}
    {errors.root?.server && <p className="error">{errors.root.server.message}</p>}
  </form>
);

Ошибки из root react-hook-form очищает при следующей отправке формы, поэтому старое общее сообщение не останется на экране.

Почитать ещё

  • react-hook-form: setError (англ.)

Слежение за значением поля

Значения полей react-hook-form хранит не в состоянии компонента, поэтому интерфейс по умолчанию не перерисовывается при вводе. Если значение поля нужно показать во время ввода, на него подписываются хуком useWatch.

import { useForm, useWatch } from 'react-hook-form';

const IMAGE_LINK = /^(https?:\/\/|data:image\/)/;

export function CardForm({ card, onDone, onCancel }: Props) {
  const { register, control, handleSubmit, formState: { errors } } = useForm<FormValues>({
    defaultValues: toFormValues(card),
  });

  const preview = useWatch({ control, name: 'preview' });

  return (
    <form onSubmit={handleSubmit(submit)} noValidate>
      <input
        {...register('preview', {
          pattern: { value: IMAGE_LINK, message: 'Ссылка должна начинаться с http:// или https://' },
        })}
        placeholder="https://..."
      />
      {IMAGE_LINK.test(preview) && <img src={preview} alt="" />}
    </form>
  );
}
  • control — объект управления формой из useForm, через него useWatch подписывается на изменения.

  • useWatch({ control, name: 'preview' }) возвращает текущее значение поля и перерисовывает компонент, когда оно меняется.

Так в ITAM Board показывают картинку по ссылке в форме карточки и аватар по ссылке в форме профиля. Так же можно сделать счётчик символов у описания: useWatch({ control, name: 'description' }).length.

Подписывайтесь только на те поля, значение которых действительно нужно во время ввода. Каждая подписка добавляет перерисовки.

Почитать ещё

  • react-hook-form: useWatch (англ.)

Компонент поля

У каждого поля формы повторяется одна и та же разметка: подпись, само поле, сообщение об ошибке или подсказка. Эту разметку выносят в компонент Field в shared/ui:

import type { ReactNode } from 'react';
import styles from './Field.module.css';

type Props = {
  label: string;
  error?: string;
  hint?: string;
  children: ReactNode;
};

export function Field({ label, error, hint, children }: Props) {
  return (
    <label className={styles.field}>
      <span className={styles.label}>{label}</span>
      {children}
      {error && <span className={styles.error}>{error}</span>}
      {!error && hint && <span className={styles.hint}>{hint}</span>}
    </label>
  );
}
<Field label="Заголовок" error={errors.title?.message}>
  <input {...register('title', { required: 'Введите заголовок' })} />
</Field>

<Field label="Дата" error={errors.date?.message} hint="Необязательно, удобно для событий">
  <input type="datetime-local" {...register('date')} />
</Field>
  • Поле передаётся через children, поэтому Field подходит для input, textarea и select.

  • Весь компонент обёрнут в <label>, и подпись связана с полем без атрибутов id и htmlFor.

  • Если есть ошибка, она показывается вместо подсказки.

Field ничего не знает о react-hook-form и получает ошибку строкой. Поэтому его можно использовать и в формах без библиотеки.

Выделить поле с ошибкой можно стилями. Например, в CSS Modules модуля Field правило .field:has(.error) input задаёт красную рамку полю, рядом с которым есть сообщение об ошибке.

Почитать ещё

  • Дока: <label>, :has()

Одна форма для создания и редактирования

Форма создания карточки и форма редактирования содержат одни и те же поля и правила. Различаются только начальные значения, запрос при отправке и текст кнопки. Поэтому делают одну форму, которая работает в двух режимах:

type Props = {
  card?: Card;
  onDone: (card: Card) => void;
  onCancel: () => void;
};

export function CardForm({ card, onDone, onCancel }: Props) {
  const {
    register,
    handleSubmit,
    setError,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({ defaultValues: toFormValues(card) });

  async function submit(values: FormValues) {
    try {
      const body = toRequestBody(values);
      const saved = card ? await updateCard(card.id, body) : await createCard(body);
      onDone(saved);
    } catch (err) {
      showServerErrors(err, setError, FIELDS);
    }
  }

  const submitLabel = card ? 'Сохранить' : 'Создать карточку';

  return (
    <form onSubmit={handleSubmit(submit)} noValidate>
      {/* поля */}
      <Button onClick={onCancel}>Отмена</Button>
      <Button type="submit" variant="primary" disabled={isSubmitting}>
        {isSubmitting ? 'Сохраняем…' : submitLabel}
      </Button>
    </form>
  );
}
// Создание: модальное окно на доске
<CardForm onDone={handleCreated} onCancel={() => setIsCreating(false)} />

// Редактирование: страница карточки
<CardForm card={card} onDone={backToBoard} onCancel={backToBoard} />
  • Если проп card передан, форма редактирует карточку: начальные значения берутся из неё, а при отправке вызывается updateCard.

  • Если card не передан, форма создаёт новую карточку.

  • Что делать после сохранения, решает тот, кто показывает форму: модальное окно закроется, а страница редактирования вернёт на доску.

defaultValues читаются при первом рендере формы. Если форма уже на экране, а карточка пришла позже, значения полей сами не обновятся. Поэтому страница редактирования показывает форму только после того, как карточка загружена.

Почитать ещё

  • react-hook-form: useForm (англ.)

Задания