useActionState и useFormStatus: ожидание и результат

мутации≈ 11 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Форма отправлена — и сразу три вопроса: чем занять кнопку на время ожидания, что показать, если сервер отказал, и как об этом узнает кнопка, лежащая тремя компонентами ниже. React 19 отвечает двумя хуками: useActionState держит результат действия и признак ожидания, а useFormStatus отдаёт этот признак любому потомку формы — без единого пропса.

Разделы темы:·

В части II отправка выглядела так: событие submit, preventDefault, сбор FormData, запрос. В React это тот же обработчик, только записанный в JSX. Меняется не он, а его окружение: вокруг одной отправки заводятся три состояния — ждём ли ответа, что вернулось и что показать, если не вышло.

Пример печатает в поле за читателя и жмёт кнопку сам. Прогноз перед запуском: сколько строк «рендер» окажется в выводе и что будет в поле, когда отправка закончится?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Три рендера: исходный, «ждём» и «пришёл ответ». Текст в поле остался: preventDefault отменил штатную отправку, и сбрасывать форму было некому.

Пример работает — и это важная оговорка: ни один хук этой статьи не делает того, чего нельзя написать руками. Разница в списке того, что приходится помнить самому: сбросить прошлую ошибку перед отправкой, вернуть pending в false во всех ветках выхода (одна забытая — и кнопка мертва до перезагрузки), выключить кнопку, чтобы второй клик не ушёл следом за первым.

Три значения, которые всегда меняются вместе, — тот самый случай из статьи про структуру состояния: их место в одном значении, а не в трёх переменных.

И последнее, что стоит заметить перед следующим разделом: preventDefault отдаёт отправку вашему обработчику, и формой дальше никто не занимается. В следующем разделе ею займётся React — и поведёт она себя иначе.

Хук берёт эти три состояния на себя. Ему передают функцию-действие и начальное значение, а он возвращает тройку.

Что вернулосьЧто это
stateто, что действие вернуло в прошлый раз; до первой отправки — начальное значение
formActionобёртка вокруг действия — её и передают в action формы
isPendingtrue, пока действие не закончилось

У самого действия подпись непривычная: первым аргументом приходит предыдущее состояние, вторым — FormData. Это редьюсер из статьи про useReducer, где роль «экшена» играют данные формы, — с одной поблажкой: ему разрешено быть асинхронным, и возвращённый промис React дождётся сам.

Прогноз: в каком порядке лягут строки вывода и что окажется в поле, когда действие закончится?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Рендеров три — столько же, сколько было в версии руками. Строка «действие пошло» напечаталась раньше рендера с isPending: true. А поле в конце пустое.

useState в компоненте не осталось ни одного: три состояния взял на себя хук. Порядок строк вывода стоит разобрать по строке — он показывает, что React сначала зовёт действие и только потом доводит до экрана признак ожидания.

С полем выходит неожиданность. Когда действие заканчивается, React сбрасывает форму — ровно как form.reset(): значения полей возвращаются к своим defaultValue. В примере из первого раздела мы отменили штатное поведение формы через preventDefault, и сбрасывать было некому; здесь формой снова занимается React.

Сброс происходит и после отказа: React не разбирает, что вернуло действие, — ему важно только то, что оно закончилось. В следующем разделе это видно своими глазами.

Ошибиться в подписи действия легко: рука сама пишет функцию, принимающую одну FormData. Компилятор про это знает — запустите и прочитайте диагностику целиком: он честно перебирает обе перегрузки useActionState и на каждой упирается в одно и то же — на месте первого параметра ждали состояние, а получили данные формы.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Сброс касается только неуправляемых полей — тех, что хранят значение в DOM. Управляемому полю из главы про формы reset() безразличен: значение в него приходит из состояния, и ближайший же рендер вернёт туда то, что лежит в переменной.

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

Действие может закончиться плохо двумя способами, и они не равны. Исключение — это поломка: его ловит ближайшая граница ошибок, компонент с формой при этом исчезает с экрана целиком. А в Next серверное действие, бросившее исключение в проде, отдаёт наружу общую фразу: текст ошибки на сервере остаётся в логах, чтобы вместе с ним наружу не уехало ничего лишнего.

Провалившаяся проверка — не поломка. Это нормальный, ожидаемый результат: адрес без собаки, занятое имя, слишком короткий пароль. Такой результат возвращают, а не бросают, — и он становится состоянием формы наравне с успехом.

Раз состояние одно, а исходов несколько, просится размеченное объединение из части III: поле-метка status, а рядом — только те данные, которые в этом исходе есть. Заодно решается вторая задача: форму React сбросит, и введённый адрес надо чем-то вернуть в поле. В примере два поля — адрес действие возвращает, а прозвище нет. Прогноз: что останется в каждом из них?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Адрес остался, прозвище пропало.

Оба исхода объясняются одинаково. Форма сбросилась к своим defaultValue: у прозвища это пустая строка, а у адреса — значение из нового состояния, которое только что вернуло действие. Сброс и новое состояние приезжают одним коммитом, поэтому читатель видит свой текст на месте, а под формой — причину отказа. Заодно подтвердилось и то, что обещала пометка выше: действие вернуло ошибку, а форма всё равно сбросилась.

Отсюда правило: всё, что должно пережить отправку, действие обязано вернуть. Что не вернули — то и пропало вместе со сбросом.

Так писать не нужно, но знать, что будет, полезно: промис действия отклоняется, React отдаёт ошибку ближайшей границе, и вместо формы читатель видит запасную разметку. Ни state, ни isPending ему уже не помогут.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Пример так и устроен: форма обёрнута в границу из главы про свои хуки и ошибки. На экране — её запасная разметка, а последний рендер формы в выводе так и остался с isPending: true. Граница — правильное место для неожиданного: база не ответила, код упал на непредусмотренном случае. Для предусмотренного отказа есть возвращаемое значение.

isPending из прошлого раздела живёт в компоненте, который владеет формой. В настоящем проекте кнопка отправки — общий компонент на весь проект: с иконкой, размерами, блокировкой. Пробрасывать в неё пропс из каждой формы значит помнить об этом в каждой форме — и однажды забыть.

useFormStatus отдаёт состояние отправки ближайшей формы сверху. Импортируется он из react-dom, а не из react, и это не случайность: хук знает про <form> — то есть про разметку браузера, а не про React вообще.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль браузера
владелец формы видит pending: false

Этот пример — листинг: песочница отдаёт глобально react и react-dom/client, а useFormStatus лежит в самом react-dom, которого здесь нет. Вывод под кодом снят с настоящего React 19. Пока идёт отправка, владелец формы не перерисовывается вовсе — «отправляем…» показывает только кнопка. Всё остальное в статье запускается по-настоящему.

Правило звучит странно ровно до того момента, пока не вспомнить, чем является статус формы. Это контекст, и провайдер в нём — сама <form>. Компонент, который эту форму рисует, стоит выше провайдера, а хук читает контекст с того места, где вызван. Соберём ту же расстановку руками — на обычном createContext из главы про общее состояние.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Две строки в выводе — и обе честные: провайдер отдаёт значение вниз, а не вбок и не вверх. С useFormStatus происходит ровно это, разница только в том, что провайдера в разметке не видно: его ставит сам React вокруг содержимого формы.

Возвращает хук не одно поле, а объект — и объект этот тоже размеченное объединение.

ПолеПока идёт отправкаВ покое
pendingtruefalse
dataFormData отправляемой формыnull
method"get" или "post"null
actionфункция или адрес из атрибутаnull

Меткой служит pending, и компилятор относится к этому серьёзно: пока проверки нет, data для него — возможный null. Сужение по метке вы разбирали в части III; здесь оно приходит вместе с чужой библиотекой, само собой.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Поле data — приятная мелочь: пока отправка идёт, из него можно достать то, что уходит на сервер, и показать это читателю («сохраняем Рико…»), не заводя ради одной строки отдельное состояние.

Действие React запускает внутри перехода — того самого, из статьи про useTransition. Отсюда и isPending: это признак незавершённого перехода, а не отдельная бухгалтерия для форм. Отсюда же ответ на вопрос, что будет, если нажать «отправить» дважды подряд.

Прогноз: второе действие отменит первое, запустится параллельно с ним или дождётся его конца? И что оно увидит в prev?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Второе действие стартовало, когда первое закончилось, и получило его результат первым аргументом — поэтому в состоянии оказались оба значения, а не последнее. Промежуточный результат на экран не попал: рендера со списком из одного значения в выводе нет, React показал новое состояние, когда очередь опустела.

Действия одной формы выстраиваются в очередь и выполняются по одному. Гонки, из-за которой ответ на первый запрос перезаписывает ответ на второй, здесь не бывает: параллельных действий у одной формы просто нет.

Очередь — не повод не выключать кнопку. Пользователь, нажавший трижды, отправит форму трижды: React выполнит все три действия подряд, а сервер запишет три подписки.

Три хука рядом легко перепутать, поэтому короткая шпаргалка: что берут, когда нужно узнать про ожидание.

Что нужноЧем братьГде вызывать
Ожидание и ответ формы там, где форма объявленаuseActionStateв компоненте с <form>
Ожидание в кнопке или подсказке внутри формыuseFormStatusв потомке <form>
Ожидание того, что формой не являетсяuseTransitionгде угодно

Ничего из этого не принадлежит Next: оба хука — часть React 19, и примеры выше запустились в обычном браузере, без сервера и маршрутов. Next меняет только природу действия: вместо функции, живущей рядом с компонентом, в action уходит серверная функция из прошлой статьи. Подпись, очередь, сброс формы и useFormStatus при этом работают ровно так же.

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

У useActionState для этого случая есть третий аргумент — permalink: адрес, на который браузер уйдёт, если форму отправили до того, как загрузился и ожил JavaScript. Без него такая отправка вернёт читателя на текущий адрес, что для страницы с динамическим содержимым не всегда то, что нужно.

  • useActionState(действие, начальное) возвращает тройку: состояние, обёрнутое действие для action формы и признак ожидания.
  • Действие — асинхронный редьюсер: первым аргументом предыдущее состояние, вторым FormData, а возвращённое значение становится новым состоянием.
  • Отказ проверки возвращают, а не бросают: исключение уходит к границе ошибок и в проде теряет текст.
  • После действия React сбрасывает форму, поэтому всё, что должно пережить отправку, действие обязано вернуть — и подставить в defaultValue.
  • useFormStatus живёт в react-dom и отдаёт статус ближайшей формы сверху: вызывать его нужно в потомке <form>, а не там, где форма объявлена.
  • Действия одной формы выстраиваются в очередь и выполняются по одному, передавая друг другу результат.

1/4Что действие, переданное в useActionState, получает первым аргументом?

2/4useFormStatus вызван в том же компоненте, где написан <form>. Что вернёт pending во время отправки?

3/4Действие проверило адрес и хочет сообщить форме «нужен адрес с собакой». Как это сделать?

4/4Неуправляемое поле, форма с action={formAction}. Что станет с введённым текстом, когда действие завершится?

Убрать три состояния

Форма ниже написана вручную: два useState, preventDefault и сбор FormData в обработчике. Перепишите её на useActionState так, чтобы в компоненте не осталось ни одного useState, а поведение сохранилось: кнопка выключена на время отправки, под формой видно сообщение.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обработчик превращается в действие: событие ему не нужно, FormData приходит вторым аргументом, а сообщение оно просто возвращает.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Заодно меняется одна деталь поведения: после отправки поле очистится — форму сбрасывает React. Если это нежелательно, введённое имя нужно вернуть из действия и подставить в defaultValue.

Кнопка без пропсов

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

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Пропс исчезает, вместо него — хук; импорт идёт из react-dom, а не из react:

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Внутри Form тот же вызов всегда возвращал бы pending: false. Хук читает статус ближайшей формы сверху по дереву, а Form сам эту форму и рисует — то есть находится выше неё. Ожидание в Form берут из третьего элемента useActionState.

Вернуть введённое

Читатель ввёл адрес с опечаткой, действие вернуло отказ — и поле опустело: приходится набирать всё заново. Почините: после отказа адрес должен остаться в поле, после успеха — исчезнуть.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В разметке поле берёт значение из состояния — в успешном исходе там пусто, и сброс оставит поле пустым:

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Обратите внимание: defaultValue здесь не «начальное значение раз и навсегда», а значение, к которому форма вернётся при следующем сбросе.