useActionState и useFormStatus: ожидание и результат
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Форма отправлена — и сразу три вопроса: чем занять кнопку на время ожидания, что показать, если сервер отказал, и как об этом узнает кнопка, лежащая тремя компонентами ниже. React 19 отвечает двумя хуками: useActionState держит результат действия и признак ожидания, а useFormStatus отдаёт этот признак любому потомку формы — без единого пропса.
В части II отправка выглядела так: событие submit, preventDefault, сбор FormData, запрос. В React это тот же обработчик, только записанный в JSX. Меняется не он, а его окружение: вокруг одной отправки заводятся три состояния — ждём ли ответа, что вернулось и что показать, если не вышло.
Пример печатает в поле за читателя и жмёт кнопку сам. Прогноз перед запуском: сколько строк «рендер» окажется в выводе и что будет в поле, когда отправка закончится?
preventDefault отменил штатную отправку, и сбрасывать форму было некому.Пример работает — и это важная оговорка: ни один хук этой статьи не делает того, чего нельзя написать руками. Разница в списке того, что приходится помнить самому: сбросить прошлую ошибку перед отправкой, вернуть pending в false во всех ветках выхода (одна забытая — и кнопка мертва до перезагрузки), выключить кнопку, чтобы второй клик не ушёл следом за первым.
Три значения, которые всегда меняются вместе, — тот самый случай из статьи про структуру состояния: их место в одном значении, а не в трёх переменных.
И последнее, что стоит заметить перед следующим разделом: preventDefault отдаёт отправку вашему обработчику, и формой дальше никто не занимается. В следующем разделе ею займётся React — и поведёт она себя иначе.
Хук берёт эти три состояния на себя. Ему передают функцию-действие и начальное значение, а он возвращает тройку.
| Что вернулось | Что это |
|---|---|
state | то, что действие вернуло в прошлый раз; до первой отправки — начальное значение |
formAction | обёртка вокруг действия — её и передают в action формы |
isPending | true, пока действие не закончилось |
У самого действия подпись непривычная: первым аргументом приходит предыдущее состояние, вторым — FormData. Это редьюсер из статьи про useReducer, где роль «экшена» играют данные формы, — с одной поблажкой: ему разрешено быть асинхронным, и возвращённый промис React дождётся сам.
Прогноз: в каком порядке лягут строки вывода и что окажется в поле, когда действие закончится?
isPending: true. А поле в конце пустое.useState в компоненте не осталось ни одного: три состояния взял на себя хук. Порядок строк вывода стоит разобрать по строке — он показывает, что React сначала зовёт действие и только потом доводит до экрана признак ожидания.
С полем выходит неожиданность. Когда действие заканчивается, React сбрасывает форму — ровно как form.reset(): значения полей возвращаются к своим defaultValue. В примере из первого раздела мы отменили штатное поведение формы через preventDefault, и сбрасывать было некому; здесь формой снова занимается React.
Сброс происходит и после отказа: React не разбирает, что вернуло действие, — ему важно только то, что оно закончилось. В следующем разделе это видно своими глазами.
Ошибиться в подписи действия легко: рука сама пишет функцию, принимающую одну FormData. Компилятор про это знает — запустите и прочитайте диагностику целиком: он честно перебирает обе перегрузки useActionState и на каждой упирается в одно и то же — на месте первого параметра ждали состояние, а получили данные формы.
Сброс касается только неуправляемых полей — тех, что хранят значение в DOM. Управляемому полю из главы про формы reset() безразличен: значение в него приходит из состояния, и ближайший же рендер вернёт туда то, что лежит в переменной.
Отсюда простое правило выбора для форм с действием: если поле не нужно читать на каждый ввод — оставляйте его неуправляемым, FormData заберёт значение при отправке. Управляемым его делают ради живой проверки, счётчика символов или зависимых полей, а не ради самой отправки.
Действие может закончиться плохо двумя способами, и они не равны. Исключение — это поломка: его ловит ближайшая граница ошибок, компонент с формой при этом исчезает с экрана целиком. А в Next серверное действие, бросившее исключение в проде, отдаёт наружу общую фразу: текст ошибки на сервере остаётся в логах, чтобы вместе с ним наружу не уехало ничего лишнего.
Провалившаяся проверка — не поломка. Это нормальный, ожидаемый результат: адрес без собаки, занятое имя, слишком короткий пароль. Такой результат возвращают, а не бросают, — и он становится состоянием формы наравне с успехом.
Раз состояние одно, а исходов несколько, просится размеченное объединение из части III: поле-метка status, а рядом — только те данные, которые в этом исходе есть. Заодно решается вторая задача: форму React сбросит, и введённый адрес надо чем-то вернуть в поле. В примере два поля — адрес действие возвращает, а прозвище нет. Прогноз: что останется в каждом из них?
Оба исхода объясняются одинаково. Форма сбросилась к своим defaultValue: у прозвища это пустая строка, а у адреса — значение из нового состояния, которое только что вернуло действие. Сброс и новое состояние приезжают одним коммитом, поэтому читатель видит свой текст на месте, а под формой — причину отказа. Заодно подтвердилось и то, что обещала пометка выше: действие вернуло ошибку, а форма всё равно сбросилась.
Отсюда правило: всё, что должно пережить отправку, действие обязано вернуть. Что не вернули — то и пропало вместе со сбросом.
Так писать не нужно, но знать, что будет, полезно: промис действия отклоняется, React отдаёт ошибку ближайшей границе, и вместо формы читатель видит запасную разметку. Ни state, ни isPending ему уже не помогут.
Пример так и устроен: форма обёрнута в границу из главы про свои хуки и ошибки. На экране — её запасная разметка, а последний рендер формы в выводе так и остался с isPending: true. Граница — правильное место для неожиданного: база не ответила, код упал на непредусмотренном случае. Для предусмотренного отказа есть возвращаемое значение.
isPending из прошлого раздела живёт в компоненте, который владеет формой. В настоящем проекте кнопка отправки — общий компонент на весь проект: с иконкой, размерами, блокировкой. Пробрасывать в неё пропс из каждой формы значит помнить об этом в каждой форме — и однажды забыть.
useFormStatus отдаёт состояние отправки ближайшей формы сверху. Импортируется он из react-dom, а не из react, и это не случайность: хук знает про <form> — то есть про разметку браузера, а не про React вообще.
Этот пример — листинг: песочница отдаёт глобально react и react-dom/client, а useFormStatus лежит в самом react-dom, которого здесь нет. Вывод под кодом снят с настоящего React 19. Пока идёт отправка, владелец формы не перерисовывается вовсе — «отправляем…» показывает только кнопка. Всё остальное в статье запускается по-настоящему.
Правило звучит странно ровно до того момента, пока не вспомнить, чем является статус формы. Это контекст, и провайдер в нём — сама <form>. Компонент, который эту форму рисует, стоит выше провайдера, а хук читает контекст с того места, где вызван. Соберём ту же расстановку руками — на обычном createContext из главы про общее состояние.
Две строки в выводе — и обе честные: провайдер отдаёт значение вниз, а не вбок и не вверх. С useFormStatus происходит ровно это, разница только в том, что провайдера в разметке не видно: его ставит сам React вокруг содержимого формы.
Возвращает хук не одно поле, а объект — и объект этот тоже размеченное объединение.
| Поле | Пока идёт отправка | В покое |
|---|---|---|
pending | true | false |
data | FormData отправляемой формы | null |
method | "get" или "post" | null |
action | функция или адрес из атрибута | null |
Меткой служит pending, и компилятор относится к этому серьёзно: пока проверки нет, data для него — возможный null. Сужение по метке вы разбирали в части III; здесь оно приходит вместе с чужой библиотекой, само собой.
Поле data — приятная мелочь: пока отправка идёт, из него можно достать то, что уходит на сервер, и показать это читателю («сохраняем Рико…»), не заводя ради одной строки отдельное состояние.
Действие React запускает внутри перехода — того самого, из статьи про useTransition. Отсюда и isPending: это признак незавершённого перехода, а не отдельная бухгалтерия для форм. Отсюда же ответ на вопрос, что будет, если нажать «отправить» дважды подряд.
Прогноз: второе действие отменит первое, запустится параллельно с ним или дождётся его конца? И что оно увидит в prev?
Действия одной формы выстраиваются в очередь и выполняются по одному. Гонки, из-за которой ответ на первый запрос перезаписывает ответ на второй, здесь не бывает: параллельных действий у одной формы просто нет.
Очередь — не повод не выключать кнопку. Пользователь, нажавший трижды, отправит форму трижды: 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, а поведение сохранилось: кнопка выключена на время отправки, под формой видно сообщение.
Обработчик превращается в действие: событие ему не нужно, FormData приходит вторым аргументом, а сообщение оно просто возвращает.
Заодно меняется одна деталь поведения: после отправки поле очистится — форму сбрасывает React. Если это нежелательно, введённое имя нужно вернуть из действия и подставить в defaultValue.
Кнопка без пропсов
Кнопка отправки — общий компонент на весь проект, и сейчас признак ожидания приходит в неё пропсом: каждая форма обязана про этот пропс помнить. Перепишите SubmitButton так, чтобы пропсов у неё не было вовсе, и объясните, почему тот же вызов внутри Form ничего бы не дал.
Пропс исчезает, вместо него — хук; импорт идёт из react-dom, а не из react:
Внутри Form тот же вызов всегда возвращал бы pending: false. Хук читает статус ближайшей формы сверху по дереву, а Form сам эту форму и рисует — то есть находится выше неё. Ожидание в Form берут из третьего элемента useActionState.
Вернуть введённое
Читатель ввёл адрес с опечаткой, действие вернуло отказ — и поле опустело: приходится набирать всё заново. Почините: после отказа адрес должен остаться в поле, после успеха — исчезнуть.
Состояние должно нести не только текст сообщения, но и то, что нужно вернуть в поле. Проще всего разметить исходы:
В разметке поле берёт значение из состояния — в успешном исходе там пусто, и сброс оставит поле пустым:
Обратите внимание: defaultValue здесь не «начальное значение раз и навсегда», а значение, к которому форма вернётся при следующем сбросе.