open atlas
↑ Back to track
React, zero to senior RCT · 04 · 03

Form actions and useActionState: mutations as a first-class React primitive

React 19 forms take action={fn}: submission runs as an async transition, FormData in, new state out. useActionState gives state, a wrapped action, isPending; useFormStatus reads pending from inside. With server functions the form submits before hydration — enhancement built in.

RCT Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

The growth team ran the numbers after a Black Friday campaign and found a hole: the email-capture form on the landing page converted at 4.1% on fast connections and 1.3% on slow ones. The page was server-rendered and looked ready in under a second — but the form’s onSubmit lived in a 400 KB hydration bundle that took eight to fourteen seconds to arrive on a median 3G connection. During that window the form was a painting of a form: users typed their email, hit a button wired to nothing, watched nothing happen, and left. The team’s own QA never saw it — office wifi hydrates in 300 ms. The eventual fix was not a loading spinner; it was moving the mutation into a form action. A real <form> with an action posts natively — the browser has known how to submit a form since 1995 — so the submission worked before a single byte of JavaScript executed, and after hydration the very same form upgraded in place to pending states and inline errors. The mutation logic did not change. What changed was who owned the submission: not an onSubmit handler that needs JS alive, but a primitive React and the browser share.

The primitive: a form whose action is a function

React 19 extends the HTML action attribute: instead of a URL, you pass a function. When the form submits, React prevents the default navigation, builds a FormData from the form’s named fields, and calls your function with it inside a transition — so the UI stays responsive while the async work runs, and React tracks its pending state for you. After the action resolves, React resets uncontrolled fields automatically (the post-submit clearing you used to write by hand). The fields arrive via name attributes, which quietly rehabilitates the uncontrolled style from lesson one: with actions, most form fields need no per-keystroke state at all.

useActionState is the state half of the primitive. You give it a reducer-shaped async function — previous state in, FormData in, new state out — plus the initial state, and it returns three things: the current state, a wrapped action to put on the form, and 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>
  );
}

Read the shape carefully: the action is a reducer over submissions. Each submit takes the previous state and produces the next — which is why returning the rejected email back in state matters: on a server-rendered failure the input can be re-seeded instead of wiped. Sequential submissions queue: if the user submits twice, the second action waits for the first, each receiving the latest state — no interleaved writes.

isPending and useFormStatus: two scopes of pending

isPending from useActionState tells you this action is in flight — use it where the form’s owner lives. useFormStatus answers a different question from a different place: called inside a component rendered under a form, it returns the pending state (plus the in-flight FormData) of the nearest enclosing form — like a context provided by the form element itself. That makes a SubmitButton component reusable across every form in the codebase without prop-drilling pending flags:

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

The classic failure: calling useFormStatus in the same component that renders the <form>. It reads the status of a parent form, and there is none — pending is forever false, the button never disables, and nothing errors. The hook must live in a child of the form.

Why this works

Why route mutations through a transition at all? Because it gives mutations the same scheduling guarantees as concurrent rendering gives reads: the submit does not block the main thread’s urgent work, pending state is tracked by React rather than by your hand-rolled isSubmitting flag (which historically leaked — set true, early return on validation failure, never set false), and related state updates batch coherently. The transition is also what lets React queue sequential submissions of the same action instead of racing them.

Progressive enhancement: the form that works before React does

Here is the part that changes architecture. With framework-provided server functions (a function marked "use server" passed to action), the framework serializes a reference to that function into the HTML. If the user submits before hydration — the Black Friday window — the browser performs a plain native form POST to the server, the framework routes it to the same function, and the page re-renders with the result. No JavaScript required on the client. After hydration, the same form upgrades: submission becomes the in-place transition with isPending, no navigation, fields preserved. useActionState participates too — its optional third argument, permalink, tells the no-JS POST which URL to target so the round-trip lands on the right page.

This is the unification the unit has been building toward: client mutation and server mutation are the same declaration. The action function is the single place where the mutation lives — validation (the shared zod schema from the previous lesson slots in verbatim), the write, and the next state. Whether it executes as a local async call or as a serialized server roundtrip is a deployment detail, not an architectural fork. The discipline it demands: model the form so it could work without JS — named fields, state returned rather than imperatively toasted — and the enhanced version inherits correctness from the degraded one, never the reverse.

Quiz

A reusable SubmitButton calls useFormStatus, but its pending is always false even while the action is clearly in flight. The button is rendered in the same component that renders the form element. What is wrong?

Quiz

What concretely happens when a user submits a form wired to a server function before the hydration bundle has loaded?

Recall before you leave
  1. 01
    Walk through the full anatomy of useActionState: what you pass in, what each returned element is for, and how sequential submissions behave.
  2. 02
    Explain why an action-based form works before hydration and what discipline that imposes on how you write the mutation.
Recap

React 19 turns form submission into a first-class mutation primitive. Passing a function to a form’s action attribute makes React intercept the submit, package the named fields into FormData, and run your function inside a transition — pending state tracked by React, uncontrolled fields reset after completion, sequential submissions queued rather than raced. useActionState shapes the mutation as a reducer over submissions: it takes (previousState, formData) => nextState plus an initial state, and returns the current state, the wrapped action for the form, and isPending; returning rejected input inside the state lets a failed submit re-seed the fields even on a full server roundtrip. useFormStatus complements it from below: any component rendered inside a form can read the nearest form’s pending flag and in-flight FormData — which makes shared submit buttons wiring-free, with the one trap that calling it beside (not below) the form yields a permanent false. The architectural payoff is progressive enhancement: a server function as the action serializes to a real POST target, so the form mutates correctly before hydration via native browser submission and upgrades afterward to the in-place transition — one declaration for client and server mutations, provided you keep the discipline of named fields and state-returned outcomes so the no-JS path stays the foundation. Now when you see a form that breaks before hydration, or a SubmitButton whose pending is always false, you know exactly which piece of the action model is missing.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.