open atlas
↑ Back to track
React patterns, senior RXP · 12 · 02

Form actions and useActionState

React 19 form actions run a function with FormData on submit; useActionState gives you pending, result, and error state for free, and useFormStatus reads submit state inside a child — so stop hand-rolling onSubmit + isSubmitting, and validate the action on the server.

RXP Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

Every React app has the same handwritten form: a preventDefault, a useState for each field or one for the whole object, an isSubmitting boolean you flip true before the fetch and false in a finally, an error state, and a try/catch that has to remember to reset all of it. It works, and it’s the same forty lines on every form, and the finally is wrong in at least one place because someone forgot to reset isSubmitting on the validation-error branch.

React 19 absorbs that ceremony into the platform. A <form action={fn}> runs your function with the submitted FormData; useActionState tracks the pending flag and the returned result for you; useFormStatus lets a nested submit button know it’s pending without a single prop drilled in. The senior shift isn’t “new hooks” — it’s that the same action runs whether you submit on the client or post to a server, so the pending/error machinery you used to rebuild per form is now one mechanism.

Goal

After this lesson you can wire a <form action={fn}> that receives FormData, manage its pending/result/error lifecycle with useActionState instead of hand-rolled isSubmitting state, read the submit’s pending status inside a child button with useFormStatus, and articulate the two failure modes: re-implementing onSubmit + isSubmitting when actions already give it to you, and forgetting that an action is a public endpoint that must validate its own input on the server.

1

A form action is a function that runs with FormData on submit — <form action={fn}> replaces onSubmit + preventDefault + reading each field. React calls your function with the form’s FormData, so you read fields by name, not by wiring controlled state to every input. There’s no event to prevent; submission is the action call.

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>
  );
}

The inputs are uncontrolled — name is the contract. You only reach for useState per field when you genuinely need to render off keystrokes (live validation, a character counter), not just to collect a value at submit.

2

useActionState wraps the action and hands back [state, dispatch, isPending] — that’s your result, error, and pending flag without any of them being hand-rolled. You give it a reducer-shaped function (prevState, formData) => nextState and an initial state. React runs it on submit, tracks the pending transition, and stores whatever you return as the new state. Validation errors are just a returned value, not a thrown exception you have to catch and stash.

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>
  );
}

Notice what’s gone: no const [isSubmitting, setIsSubmitting], no try/catch, no finally to reset flags. The pending flag comes from the transition React is already managing; the error is just the state you returned.

3

useFormStatus reads the enclosing form’s submit state from inside a child — so a shared <SubmitButton> knows it’s pending with zero props. The catch that trips people: it must be called in a component rendered inside the <form>, not in the component that renders the form. That boundary is the whole point — it lets you ship a reusable submit button that any form can drop in, and it lights up automatically.

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 is for the button’s local feedback; useActionState’s isPending is for the form-owner’s logic (disabling a fieldset, showing a result). Use whichever sits where you need the signal — don’t drill isPending down when useFormStatus reads it natively.

4

The same action runs on the client or as a server action — but a server action is a public HTTP endpoint, so it must validate its own input. Mark a function "use server" and <form action={serverAction}> posts the FormData to the server; the form works before hydration, and progressive enhancement is real. The senior trap: client-side validation (the type="email", the disabled button) is UX, not a security boundary. Anyone can POST arbitrary FormData straight to that action, so it must re-validate and authorize on the server, as if the client never ran.

// 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 };
}

The action’s signature is identical to the client version — that’s the unification. What changes is that the boundary moved across the network, and everything on the far side treats its input as hostile.

Worked example

Same newsletter form, before and after actions — watch forty lines of state collapse. The “before” is the canonical hand-rolled form: controlled value, an isSubmitting boolean, an error string, and a try/catch/finally juggling all three.

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>
  );
}

Four useStates, a manual preventDefault, and a finally that’s a latent bug magnet. The “after” lets the action own the lifecycle: validation error is a returned value, pending comes from useActionState, and the button reads its own status.

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>
  );
}

The state count drops from four pieces to one returned object; isSubmitting and its reset are gone entirely; the early-return error path can’t leave a stuck spinner because there’s no flag to leave stuck. The pattern doesn’t add power you couldn’t get by hand — it deletes the bookkeeping that was wrong on every third form.

Common mistake

The dominant mistake when adopting React 19 forms is keeping the old machinery and adding the new one: a <form action={action}> that still wires onSubmit, or a component that calls useActionState but also keeps an isSubmitting useState it sets manually. Now two systems track the same pending state and they drift — the button disables off one flag while the fieldset reads the other. If you’re on actions, delete the hand-rolled isSubmitting, the preventDefault, and the finally; let isPending (or useFormStatus) be the single source. Mixing them is strictly worse than either alone.

Edge cases

A server action is reachable by anyone who can craft an HTTP POST — it is not protected by the form’s client-side required, type="email", or disabled button. So "use server" functions carry the same obligations as any API route: validate the shape (parse, don’t trust formData.get to be a string — it can be a File or null), authorize the caller (requireSession), and rate-limit if it writes. Client validation still earns its place as instant UX, but it’s a convenience layer over the server gate, never a replacement for it. Treat every action as a public endpoint, because it is one.

Check yourself
Quiz

A teammate ships a React 19 form with <form action={action}> driven by useActionState, but also keeps a separate const [isSubmitting, setIsSubmitting] = useState(false) that they flip inside the action, plus an onSubmit that calls preventDefault. What's the senior correction?

Recap

React 19 turns form submission into a platform mechanism instead of forty lines of hand-rolled state. <form action={fn}> runs your function with the submitted FormData — no preventDefault, no controlled state per field, read by name. useActionState(fn, initial) returns [state, dispatch, isPending]: your result and validation errors are just the value you return, and the pending flag comes from the transition React already manages — so the isSubmitting boolean, the try/catch, and the bug-prone finally reset all disappear. useFormStatus reads that pending state from inside a child, letting a reusable submit button light up with no props drilled. The same action runs on the client or as a server action ("use server"), which is the unification — but a server action is a public endpoint, so it must re-validate and authorize its input as if the client never ran. The two failure modes to name out loud: don’t re-implement onSubmit + isSubmitting when the action already gives you both, and never let client-side validation stand in for server-side validation. Match the signal to where you need it — isPending for the form owner, useFormStatus for the button — and let the action own the lifecycle.

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 4 done

Something unclear?

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

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.