open atlas
↑ К треку
Паттерны React RXP · 12 · 02

Form actions и useActionState

Form actions в React 19 при сабмите запускают функцию с FormData; useActionState бесплатно даёт pending, result и error, а useFormStatus читает состояние сабмита внутри потомка — хватит вручную городить onSubmit + isSubmitting, и валидируй action на сервере.

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

В каждом React-приложении есть одна и та же написанная вручную форма: preventDefault, useState на каждое поле или один на весь объект, булев флаг isSubmitting, который ты ставишь в true перед запросом и в false в finally, состояние error и try/catch, который должен не забыть всё это сбросить. Оно работает, и это одни и те же сорок строк на каждой форме, и finally хотя бы в одном месте неправильный, потому что кто-то забыл сбросить isSubmitting в ветке с ошибкой валидации.

React 19 вбирает эту церемонию в платформу. <form action={fn}> запускает твою функцию с отправленным FormData; useActionState сам отслеживает флаг pending и возвращённый результат; useFormStatus позволяет вложенной кнопке сабмита узнать, что она в pending, без единого проброшенного пропса. Сдвиг уровня senior не в «новых хуках» — а в том, что один и тот же action работает, сабмитишь ли ты на клиенте или постишь на сервер, поэтому машинерия pending/error, которую ты раньше пересобирал под каждую форму, теперь один механизм.

Цель

После этого урока ты можешь собрать <form action={fn}>, получающую FormData, управлять её жизненным циклом pending/result/error через useActionState вместо самописного состояния isSubmitting, читать pending-статус сабмита внутри дочерней кнопки через useFormStatus и сформулировать два режима отказа: переизобретение onSubmit + isSubmitting, когда actions уже дают это, и забывание, что action — это публичный эндпойнт, который обязан валидировать свой ввод на сервере.

1

Form action — это функция, которая при сабмите запускается с FormData; <form action={fn}> заменяет onSubmit + preventDefault + чтение каждого поля. React вызывает твою функцию с FormData формы, поэтому ты читаешь поля по name, а не подключаешь контролируемое состояние к каждому инпуту. Никакого события предотвращать не нужно; сабмит — это и есть вызов action.

function ContactForm() {
  async function submit(formData: FormData) {
    const email = formData.get("email") as string;
    await sendMessage(email, formData.get("body") as string);
  }
  return (
    <form action={submit}>
      <input name="email" type="email" />
      <textarea name="body" />
      <button>Send</button>
    </form>
  );
}

Инпуты неконтролируемые — контракт это name. К useState на поле ты тянешься, только когда тебе действительно нужно рендерить по нажатиям клавиш (живая валидация, счётчик символов), а не просто собрать значение на сабмите.

2

useActionState оборачивает action и возвращает [state, dispatch, isPending] — это твой result, error и флаг pending, и ни один из них не написан вручную. Ты даёшь ему функцию в форме редьюсера (prevState, formData) => nextState и начальное состояние. React запускает её на сабмите, отслеживает переход в pending и сохраняет то, что ты вернул, как новый state. Ошибки валидации — это просто возвращённое значение, а не выброшенное исключение, которое нужно ловить и прятать.

type State = { ok: boolean; error?: string };

async function submitContact(_prev: State, formData: FormData): Promise<State> {
  const email = String(formData.get("email") ?? "");
  if (!email.includes("@")) return { ok: false, error: "Enter a valid email" };
  await sendMessage(email, String(formData.get("body") ?? ""));
  return { ok: true };
}

function ContactForm() {
  const [state, action, isPending] = useActionState(submitContact, { ok: false });
  return (
    <form action={action}>
      <input name="email" type="email" />
      <textarea name="body" />
      {state.error && <p role="alert">{state.error}</p>}
      <button disabled={isPending}>{isPending ? "Sending…" : "Send"}</button>
    </form>
  );
}

Заметь, что исчезло: нет const [isSubmitting, setIsSubmitting], нет try/catch, нет finally для сброса флагов. Флаг pending приходит из перехода, которым React и так управляет; error — это просто состояние, которое ты вернул.

3

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

import { useFormStatus } from "react-dom";

function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus(); // reads the parent <form>, not props
  return <button disabled={pending}>{pending ? "Submitting…" : children}</button>;
}

// any form gets the pending button for free
<form action={action}>
  <input name="email" />
  <SubmitButton>Save</SubmitButton>
</form>

useFormStatus — для локальной обратной связи кнопки; isPending от useActionState — для логики владельца формы (отключить fieldset, показать результат). Используй то, что сидит там, где тебе нужен сигнал, — не пробрасывай isPending вниз, когда useFormStatus читает его нативно.

4

Один и тот же action работает на клиенте или как server action — но server action это публичный HTTP-эндпойнт, поэтому он обязан валидировать свой ввод. Пометь функцию "use server", и <form action={serverAction}> постит FormData на сервер; форма работает ещё до гидрации, и прогрессивное улучшение настоящее. Ловушка уровня senior: клиентская валидация (type="email", отключённая кнопка) — это UX, а не граница безопасности. Кто угодно может запостить произвольный FormData прямо в этот action, поэтому он обязан перевалидировать и авторизовать на сервере, как будто клиент вообще не выполнялся.

// app/actions.ts
"use server";
import { z } from "zod";

const Contact = z.object({ email: z.string().email(), body: z.string().min(1).max(2000) });

export async function submitContact(_prev: State, formData: FormData): Promise<State> {
  const parsed = Contact.safeParse({ email: formData.get("email"), body: formData.get("body") });
  if (!parsed.success) return { ok: false, error: "Invalid input" }; // server is the real gate
  await requireSession();                 // authz here, never trust the client
  await db.messages.insert(parsed.data);
  return { ok: true };
}

Сигнатура action идентична клиентской версии — в этом и унификация. Меняется то, что граница переехала через сеть, и всё по ту сторону относится к своему вводу как к враждебному.

Разбор примера

Та же форма рассылки, до и после actions — смотри, как сорок строк состояния схлопываются. «До» — это каноническая самописная форма: контролируемое значение, булев isSubmitting, строка error и try/catch/finally, жонглирующий всеми тремя.

function Newsletter() {
  const [email, setEmail] = useState("");
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [done, setDone] = useState(false);

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!email.includes("@")) { setError("Invalid email"); return; }
    setIsSubmitting(true); setError(null);
    try {
      await subscribe(email);
      setDone(true);
    } catch {
      setError("Something went wrong");
    } finally {
      setIsSubmitting(false); // easy to forget on the early-return branch above
    }
  }
  return (
    <form onSubmit={onSubmit}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      {error && <p>{error}</p>}
      <button disabled={isSubmitting}>{isSubmitting ? "…" : "Subscribe"}</button>
    </form>
  );
}

Четыре useState, ручной preventDefault и finally, который — латентный магнит для багов. «После» отдаёт жизненный цикл во владение action: ошибка валидации это возвращённое значение, pending приходит из useActionState, а кнопка читает свой собственный статус.

type State = { done: boolean; error?: string };

async function subscribeAction(_prev: State, fd: FormData): Promise<State> {
  const email = String(fd.get("email") ?? "");
  if (!email.includes("@")) return { done: false, error: "Invalid email" };
  try { await subscribe(email); } catch { return { done: false, error: "Something went wrong" }; }
  return { done: true };
}

function Newsletter() {
  const [state, action] = useActionState(subscribeAction, { done: false });
  if (state.done) return <p>Subscribed ✓</p>;
  return (
    <form action={action}>
      <input name="email" type="email" />
      {state.error && <p role="alert">{state.error}</p>}
      <SubmitButton>Subscribe</SubmitButton>
    </form>
  );
}

Число кусков состояния падает с четырёх до одного возвращённого объекта; isSubmitting и его сброс исчезают совсем; путь ошибки с ранним возвратом не может оставить зависший спиннер, потому что нет флага, который мог бы зависнуть. Паттерн не добавляет мощи, которую ты не получил бы руками, — он удаляет бухгалтерию, которая была неправильной на каждой третьей форме.

Частая ошибка

Главная ошибка при переходе на формы React 19 — оставить старую машинерию и добавить новую: <form action={action}>, которая всё ещё подключает onSubmit, или компонент, который вызывает useActionState, но при этом держит isSubmitting через useState, которое сам выставляет. Теперь две системы отслеживают одно и то же состояние pending, и они расходятся — кнопка отключается по одному флагу, а fieldset читает другой. Если ты на actions — удали самописный isSubmitting, preventDefault и finally; пусть isPending (или useFormStatus) будет единственным источником. Смешивать их строго хуже, чем любой по отдельности.

Граничные случаи

Server action достижим для всякого, кто может сформировать HTTP POST, — он не защищён клиентскими required, type="email" или отключённой кнопкой формы. Поэтому функции с "use server" несут те же обязательства, что и любой API-роут: валидировать форму данных (парси, не доверяй, что formData.get вернёт строку — это может быть File или null), авторизовать вызывающего (requireSession) и ограничивать частоту, если они пишут. Клиентская валидация всё ещё заслуживает места как мгновенный UX, но это слой удобства над серверной границей, никогда не замена ей. Относись к каждому action как к публичному эндпойнту, потому что он им и является.

Проверь себя
Викторина

Коллега отгружает форму React 19 с <form action={action}>, управляемую useActionState, но при этом держит отдельный const [isSubmitting, setIsSubmitting] = useState(false), который выставляет внутри action, плюс onSubmit, вызывающий preventDefault. В чём senior-исправление?

Итог

React 19 превращает сабмит формы в механизм платформы вместо сорока строк самописного состояния. <form action={fn}> запускает твою функцию с отправленным FormData — без preventDefault, без контролируемого состояния на поле, чтение по name. useActionState(fn, initial) возвращает [state, dispatch, isPending]: твой result и ошибки валидации — это просто возвращённое значение, а флаг pending приходит из перехода, которым React уже управляет, — так булев isSubmitting, try/catch и багоопасный сброс в finally исчезают. useFormStatus читает это состояние pending изнутри потомка, позволяя переиспользуемой кнопке сабмита загораться без проброшенных пропсов. Один и тот же action работает на клиенте или как server action ("use server") — в этом унификация, — но server action это публичный эндпойнт, поэтому он обязан перевалидировать и авторизовать свой ввод, как будто клиент вообще не выполнялся. Два режима отказа, которые надо называть вслух: не переизобретай onSubmit + isSubmitting, когда action уже даёт и то и другое, и никогда не позволяй клиентской валидации подменять серверную. Подбирай сигнал под место, где он нужен, — isPending для владельца формы, useFormStatus для кнопки, — и отдай жизненный цикл во владение action.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.