Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 3 min read

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

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

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.