open atlas
↑ К треку
React с нуля до senior RCT · 04 · 03

Form actions и useActionState: мутации как первоклассный примитив React

React 19: форма берёт action={fn} — сабмит идёт как async-transition, FormData на входе, состояние на выходе. useActionState даёт state, обёрнутый action, isPending; useFormStatus читает pending изнутри. С серверными функциями форма шлёт POST до гидрации — enhancement встроен.

RCT Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Команда роста посчитала цифры после черно-пятничной кампании и нашла дыру: форма захвата email на лендинге конвертировала 4,1% на быстрых соединениях и 1,3% на медленных. Страница рендерилась на сервере и выглядела готовой меньше чем за секунду — но onSubmit формы жил в 400-килобайтном бандле гидрации, который на медианном 3G ехал восемь–четырнадцать секунд. Всё это окно форма была картиной формы: пользователи вводили email, жали кнопку, подключённую ни к чему, смотрели, как ничего не происходит, и уходили. Собственный QA команды этого не видел никогда — офисный wifi гидрирует за 300 мс. Финальным фиксом стал не спиннер загрузки, а перенос мутации в form action. Настоящая <form> с action постится нативно — браузер умеет отправлять формы с 1995 года, — так что сабмит работал до исполнения первого байта JavaScript, а после гидрации та же самая форма апгрейдилась на месте до pending-состояний и инлайновых ошибок. Логика мутации не изменилась. Изменилось, кто владеет сабмитом: не onSubmit-обработчик, которому нужен живой JS, а примитив, общий для React и браузера.

Примитив: форма, чей action — функция

React 19 расширяет HTML-атрибут action: вместо URL можно передать функцию. На сабмите React отменяет дефолтную навигацию, собирает FormData из именованных полей формы и вызывает вашу функцию с ним внутри transition — UI остаётся отзывчивым, пока идёт асинхронная работа, а pending-состояние React отслеживает за вас. Когда action разрешился, React автоматически сбрасывает uncontrolled-поля (та пост-сабмитная очистка, которую раньше писали руками). Поля приезжают через атрибуты name, что тихо реабилитирует uncontrolled-стиль из первого урока: с actions большинству полей формы вообще не нужно посимвольное состояние.

useActionState — половина примитива, отвечающая за состояние. Вы отдаёте ему async-функцию редьюсерной формы — предыдущее состояние на входе, FormData на входе, новое состояние на выходе — плюс начальное состояние, и получаете три вещи: текущее состояние, обёрнутый action для формы и isPending.

async function subscribe(prevState, formData) {
  const email = formData.get("email");
  const parsed = emailSchema.safeParse(email);
  if (!parsed.success) {
    return { ok: false, error: "Enter a valid email", email };
  }
  const res = await api.subscribe(parsed.data);
  if (!res.ok) {
    return { ok: false, error: "Try again in a minute", email };
  }
  return { ok: true, error: null, email: "" };
}

function SignupForm() {
  const [state, formAction, isPending] = useActionState(subscribe, {
    ok: false,
    error: null,
    email: "",
  });

  return (
    <form action={formAction}>
      <input name="email" defaultValue={state.email} />
      <button disabled={isPending}>
        {isPending ? "Subscribing…" : "Subscribe"}
      </button>
      {state.error ? <p role="alert">{state.error}</p> : null}
    </form>
  );
}

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

isPending и useFormStatus: два масштаба pending

isPending из useActionState говорит, что этот action в полёте — используйте его там, где живёт владелец формы. useFormStatus отвечает на другой вопрос из другого места: вызванный в компоненте, отрендеренном под формой, он возвращает pending-состояние (плюс летящий FormData) ближайшей объемлющей формы — как контекст, предоставленный самим элементом формы. Это делает компонент SubmitButton переиспользуемым во всех формах кодовой базы без проброса pending-флагов:

function SubmitButton({ children }) {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Working…" : children}</button>;
}

Классический провал: вызвать useFormStatus в том же компоненте, который рендерит <form>. Хук читает статус родительской формы, а её нет — pending навсегда false, кнопка не отключается, и ничего не падает. Хук обязан жить в потомке формы.

Почему это работает

Зачем вообще гнать мутации через transition? Затем, что это даёт мутациям те же гарантии планирования, какие конкурентный рендеринг даёт чтениям: сабмит не блокирует срочную работу главного потока, pending-состояние отслеживает React, а не ваш самодельный флаг isSubmitting (который исторически протекал: поставили true, ранний return на провале валидации — и false не поставили никогда), и связанные обновления состояния батчатся когерентно. Transition — это ещё и то, что позволяет React выстраивать последовательные сабмиты одного action в очередь вместо гонки.

Progressive enhancement: форма, работающая раньше React

А вот часть, меняющая архитектуру. С серверными функциями фреймворка (функция с пометкой "use server", переданная в action) фреймворк сериализует ссылку на неё прямо в HTML. Если пользователь отправляет форму до гидрации — то самое черно-пятничное окно — браузер выполняет обычный нативный POST на сервер, фреймворк маршрутизирует его в ту же функцию, и страница перерендеривается с результатом. Ноль JavaScript на клиенте. После гидрации та же форма апгрейдится: сабмит становится transition на месте, с isPending, без навигации, с сохранёнными полями. useActionState тоже участвует — его необязательный третий аргумент permalink говорит no-JS-POST-у, на какой URL целиться, чтобы серверный круг приземлился на нужную страницу.

Это и есть унификация, к которой шёл весь юнит: клиентская и серверная мутации — одно объявление. Функция action — единственное место, где живёт мутация: валидация (общая zod-схема из прошлого урока встаёт дословно), запись и следующее состояние. Выполнится ли она локальным async-вызовом или сериализованным серверным кругом — деталь деплоя, а не архитектурная развилка. Дисциплина, которой это требует: моделируйте форму так, чтобы она могла работать без JS — именованные поля, исход, возвращаемый состоянием, а не императивным тостом, — и улучшенная версия наследует корректность от деградированной, а не наоборот.

Викторина

Переиспользуемый SubmitButton вызывает useFormStatus, но его pending всегда false, хотя action явно в полёте. Кнопка рендерится в том же компоненте, который рендерит элемент form. Что не так?

Викторина

Что конкретно происходит, когда пользователь отправляет форму, привязанную к серверной функции, до загрузки бандла гидрации?

Вспомните перед уходом
  1. 01
    Разберите полную анатомию useActionState: что передаётся на вход, для чего каждый элемент результата и как ведут себя последовательные сабмиты.
  2. 02
    Объясните, почему форма на action работает до гидрации и какую дисциплину это накладывает на написание мутации.
Итог

React 19 превращает отправку формы в первоклассный примитив мутации. Передача функции в атрибут action заставляет React перехватить сабмит, упаковать именованные поля в FormData и выполнить вашу функцию внутри transition — pending-состояние отслеживает React, uncontrolled-поля сбрасываются после завершения, последовательные сабмиты выстраиваются в очередь вместо гонки. useActionState оформляет мутацию редьюсером над сабмитами: принимает (previousState, formData) => nextState плюс начальное состояние и возвращает текущее состояние, обёрнутый action для формы и isPending; возврат отвергнутого ввода внутри состояния позволяет провалившемуся сабмиту заново засеять поля даже на полном серверном круге. useFormStatus дополняет снизу: любой компонент внутри формы читает pending-флаг и летящий FormData ближайшей формы — общие кнопки сабмита не требуют проводки, с одной ловушкой: вызов рядом с формой, а не под ней, даёт вечный false. Архитектурный выигрыш — progressive enhancement: серверная функция в роли action сериализуется в настоящую цель POST, так что форма корректно мутирует до гидрации нативной отправкой браузера и апгрейдится после до transition на месте — одно объявление для клиентских и серверных мутаций, при дисциплине именованных полей и исходов, возвращаемых состоянием, чтобы no-JS-путь оставался фундаментом. Теперь, когда встретишь форму, теряющую сабмиты до гидрации, или SubmitButton с вечным false в pending, — знаешь, какой части action-модели не хватает и как её поставить.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.