DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on • Originally published at github.com

I built a useActionState lab so you can watch React 19 form actions run (pending, errors, and the accumulator)

For years, every React form submit looked the same: a useState loading boolean, a try/catch, a result state, and a setLoading(false) you had to remember in every branch. React 19's form actions fold all of that into the framework. To make the new model concrete, I built a lab where you can watch the whole state machine run.

▶ Live demo: https://useactionstate-lab.vercel.app/
Source (React 19 + TS): https://github.com/dev48v/useactionstate-lab

Three demos, each with the exact code beside it.

1. useActionState is useReducer with a built-in pending flag

You give a <form> an action, and React gives you back the state, a wrapped action, and a pending boolean:

const [state, formAction, isPending] = useActionState(
  async (prev, formData) => {
    const name = formData.get("username");
    await saveToApi(name);
    return { status: "success", message: `Saved ${name}` }; // becomes next state
  },
  { status: "idle" }
);

<form action={formAction}>
  <input name="username" />
  <button disabled={isPending}>Save</button>
</form>
Enter fullscreen mode Exit fullscreen mode

The action's return value becomes the next state. That's the whole model. There's no setState inside the action, no separate loading flag — React flips isPending to true when the action starts and back to false when it settles. In the demo a transition log prints every one of those flips so you can see the idle → submitting → success/error machine turn.

Note the signature: (previousState, formData) => nextState. It's a reducer. Which leads to the third demo.

2. useFormStatus reads the parent form — no prop drilling

The submit button doesn't need a pending prop passed down. It asks the form directly:

function Submit() {
  const { pending } = useFormStatus();      // reads the nearest parent <form>
  return <button disabled={pending}>{pending ? "Saving…" : "Save"}</button>;
}

<form action={sendAction}>
  <input name="msg" />
  <Submit />        {/* ✅ must be a CHILD of the form */}
</form>
Enter fullscreen mode Exit fullscreen mode

The gotcha that gets everyone: useFormStatus reads the nearest parent <form>. If you call it in the same component that renders the <form>, it has no parent form to look at and returns pending: false forever. It has to live in a child component. The demo has a live pending dot and a self-disabling button, both reading status with zero props threaded through.

3. The accumulator: each submit builds on the last

Because the action is (prev, formData) => next, the previous result flows back in:

const [vote, voteAction] = useActionState(
  async (prev) => {
    await record();
    return { count: prev.count + 1, prev: prev.count };  // prev = last return
  },
  { count: 0, prev: null }
);
Enter fullscreen mode Exit fullscreen mode

No external counter, no useRef to remember the last value. It's useReducer semantics — but with the pending flag and form wiring you'd otherwise build yourself. In the demo you can watch prevState.count → nextState.count on every click.

Why this matters

The React 19 form primitives collapse the four things you used to hand-roll (pending, error, result, reset) into one return tuple, and they compose with <form action> so a submit works even before hydration. Once you've seen the state machine — the pending flip, the returned-state-becomes-next-state rule, the parent-form status — the API stops feeling magic and starts feeling like useReducer that happens to know about forms.

Everything here is the real API running client-side; no server needed to see the mechanism. If it made form actions click, a star helps others find it: https://github.com/dev48v/useactionstate-lab

Top comments (0)