Урок по треку React 8 мин

Формы в React 19: от value до actions

Контролируемые поля, actions, useActionState и useFormStatus.

Формы спрашивают почти всегда: контролируемые и неконтролируемые поля, валидация, библиотеки. В React 19 добавились actions, и про них теперь тоже спрашивают.

Контролируемое поле

Значение живёт в состоянии React, поле лишь показывает его:

const [email, setEmail] = useState('');
<input value={email} onChange={(e) => setEmail(e.target.value)} />

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

Детали, на которых ловят:

  • value без onChange делает поле только для чтения, React пишет предупреждение. Если так и задумано, укажите readOnly.
  • onChange в React срабатывает на каждый ввод символа (как нативное событие input), а не при потере фокуса, как нативный change.
  • Значение поля всегда строка, даже у type="number". Число берут через Number(e.target.value) или e.target.valueAsNumber.
  • У чекбокса управляют checked, у <select> пишут value на самом select, у <textarea> тоже value, а не дочерний текст.
  • Если value сначала undefined, а потом строка, React предупредит о переходе из неконтролируемого в контролируемое. Начальное значение задавайте ''.

Неконтролируемое поле

Значение живёт в DOM, React его не хранит. Начальное значение задают defaultValue (defaultChecked у чекбокса), читают при отправке через FormData или ref:

function Signup() {
  function handleSubmit(e) {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    console.log(data.get('email'));
  }
  return (
    <form onSubmit={handleSubmit}>
      <input name="email" defaultValue="" />
      <button>Отправить</button>
    </form>
  );
}

Меньше рендеров и кода. <input type="file"> всегда неконтролируемый.

Actions в React 19

В action формы можно передать функцию. React вызовет её с FormData, сам отменит перезагрузку страницы, а после успешного выполнения сбросит неконтролируемые поля формы.

<form action={async (formData) => { await save(formData.get('title')); }}>
  <input name="title" />
  <button>Сохранить</button>
</form>

Функция-action выполняется внутри перехода (transition), поэтому может быть асинхронной.

useActionState

Хранит результат последнего action и флаг ожидания:

import { useActionState } from 'react';

async function subscribe(prevState, formData) {
  const email = formData.get('email');
  if (!email.includes('@')) return { error: 'Неверный email' };
  await api.subscribe(email);
  return { ok: true };
}

function Form() {
  const [state, formAction, isPending] = useActionState(subscribe, {});
  return (
    <form action={formAction}>
      <input name="email" />
      <button disabled={isPending}>Подписаться</button>
      {state.error && <p>{state.error}</p>}
    </form>
  );
}

Обратите внимание: функция получает сначала предыдущее состояние, потом FormData. Возвращённое значение становится новым state.

useFormStatus

Хук из react-dom возвращает { pending, data, method, action } родительской формы. Поэтому вызывать его нужно в компоненте, который рендерится внутри <form>, а не в том же компоненте, где форма объявлена:

import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? 'Сохраняю…' : 'Сохранить'}</button>;
}

Так кнопку можно переиспользовать в любой форме без пробрасывания isPending.

useOptimistic

Показывает ожидаемый результат до ответа сервера:

const [optimisticTodos, addOptimistic] = useOptimistic(
  todos,
  (current, newTodo) => [...current, { ...newTodo, sending: true }],
);

async function action(formData) {
  const todo = { title: formData.get('title') };
  addOptimistic(todo);          // интерфейс обновился сразу
  await createTodo(todo);       // после завершения action оптимистичное
}                               // значение заменится реальным todos

addOptimistic вызывают внутри action или startTransition. Когда переход завершится, React вернётся к настоящему todos: если запрос упал и todos не обновили, временная запись исчезнет сама.

Библиотеки

React Hook Form строится на неконтролируемых полях: register('email') вешает ref и обработчики, состояние формы не вызывает рендер на каждый символ. Валидацию описывают схемой (Zod, Yup) через резолвер. Для сложных форм с массивами полей (useFieldArray) и десятками полей это стандарт.

Formik исторически популярен, но построен на контролируемом состоянии, поэтому в больших формах даёт больше перерисовок, чем React Hook Form.

Валидация

  • Нативные атрибуты (required, type="email", minLength, pattern) работают бесплатно и доступны.
  • Клиентская валидация нужна для удобства, серверная обязательна: клиентскую можно обойти.
  • Ошибку показывают рядом с полем и связывают через aria-describedby, поле помечают aria-invalid.
  • Валидировать при каждом символе раздражает: обычно проверяют при потере фокуса, а после первой ошибки при вводе.

Частые ошибки

  • value без onChange и удивление, что поле «не печатает».
  • useFormStatus в том же компоненте, где <form>: pending всегда false.
  • Перепутанный порядок аргументов в функции для useActionState.
  • Сравнение e.target.value === 5 у числового поля.
  • Доверие только клиентской валидации.

Что спросят на собесе

  • Чем контролируемое поле отличается от неконтролируемого, когда что выбрать?
  • Как работают useActionState и useFormStatus?
  • Что такое оптимистичное обновление и как его сделать в React 19?
  • Почему React Hook Form быстрее формы на useState?
  • Где должна быть валидация и как показать ошибку доступно?

Закрепите за 5 минут

После урока в тренажёре 3-5 проверочных вопросов, дальше модуль уходит в повторения.