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