Three parts into this series, and if you'd asked me to define the word sitting underneath every single hook I'd taught you, I'm not sure I could have done it cleanly. That's not false modesty. Go back and read Parts 1 through 3. I used the word "Action" constantly. I never once stopped to say what one actually is.
Part 1 opened with three state variables I'd hand-rolled for years, a result, a pending flag, an error, and showed how useActionState collapses all three into one call. Part 2 took a comment box and made it feel instant before the server had even replied. Part 3 let a submit button three components away read a form's pending state with zero props passed down. Three different problems, three different hooks, and every single one of them was quietly leaning on the same mechanism without me ever naming it out loud.
That's the gap this post closes. Not a new bug this time. The concept the last three bugs were all symptoms of.
Here's the actual definition, straight from React's own docs, not a paraphrase I'm softening for effect. A function called inside startTransition is an Action. That's it. That's the whole qualifying test.
That function above could sit in your codebase forever and never once behave like an Action. What flips the switch has nothing to do with anything written inside it. It's entirely about the doorway it walks through. Four doorways do this: a direct startTransition call, useTransition's version of the same call, a form's action prop taking a function instead of a string, or useActionState. Walk a function through any of them and it stops being a plain async function you're tracking by hand. What React takes over automatically is the Transition itself, the pending tracking underneath it. What it does not universally hand you is the function's return value. useTransition gives you isPending and nothing else, a raw startTransition call doesn't return anything at all. useActionState is the one doorway built specifically to catch that return value and hold onto it as state, which is exactly why isPending and a real result showed up together back in Part 1 and nowhere else in this series. More on the ordering side of this once useTransition shows up properly below.
Once that clicked for me, Parts 1 through 3 stopped looking like three separate hooks and started looking like three different windows onto the same room. useFormStatus reads the pending status of the nearest parent form, and now you know why, that pending status only exists in the first place because something wrapped the submission in a Transition. useOptimistic is the odd one out. It isn't a door into becoming an Action at all. It's a setter you call from inside one that's already running. Call it outside a Transition and it just won't behave the way Part 2 showed you.
Part 1's opening line still holds up: every form needed the same three pieces of state, wired by hand, every time. That pattern wasn't unique to forms either. A delete button, a follow toggle, a checkout step, all needed the identical scaffolding rebuilt from scratch. React 19 added the ability to use async functions inside transitions in the first place, which is the foundation everything else in this series stands on. What you build on that foundation, whether you get a tracked result back or just a pending flag, depends on which of the four doorways you actually walk through. useActionState is the one that goes furthest, catching the return value as state. The others give you less by design.
Before Actions existed, a form's only real path to running code was onSubmit, and that path came with requirements you had to remember every time. Call preventDefault, manually construct FormData from the event target, manage loading and error state by hand.
Swap onSubmit for a function passed straight to action and the mental model shifts more than the syntax suggests. Nothing calls preventDefault, not because you forgot it, but because React already knows this is an Action before the form ever fires, so the browser's default full-page reload was never going to happen in the first place. The function also stops receiving an event. It receives FormData directly, already built from every named field on the page, which is one less step you used to write by hand every time. And the request method itself stops being something you configure at all. A function passed to action always submits as POST, whatever you write in a method attribute sitting right next to it. That last one is the exact fact Part 3 ran into from the useFormStatus side, back when method kept coming back 'post' no matter what I expected walking in. It was never reading an attribute. It was reading the Action underneath it.
onSubmit still earns its keep for one job specifically. Anything that has to stop a submission before it starts, checking two password fields match, belongs there. By the time code inside the Action itself runs, the submission has already begun, so nothing in there can be the thing that prevents it.
An Action doesn't have to be async. A plain synchronous function handed to the same four doors still counts, React still manages its lifecycle, but the pending window is usually too short to build UI around.
The instant await shows up inside that function, everything from Parts 1 through 3 becomes worth having. React 18 required startTransition's callback to be synchronous. React 19 dropped that restriction, and that single change is what let useActionState, , and useTransition all accept async functions directly instead of you managing the async boundary by hand.
Everything in this series so far has been a client Action, code that runs in the browser whether or not it talks to a server underneath. That version works identically in any React 19 setup, no framework required.
Server Functions are a related but genuinely separate idea, and they only exist in frameworks with Server Component support, Next.js being the obvious one. React's own docs are specific about the relationship here, and it's worth getting right instead of using the two terms interchangeably like I nearly did drafting this: a Server Function only becomes a Server Action once it's passed to an action prop or called from inside an Action. Not every Server Function is a Server Action. Every Server Action used this way is a Server Function.
That single formData argument is the shape a Server Function receives when it's handed directly to a form's action prop. It's worth flagging because it's not interchangeable with useActionState as-is, which always calls its function with (previousState, formData), the two-argument shape Part 1 introduced. Reusing server logic through useActionState means writing for that shape from the start, not dropping in a single-argument version and hoping React adapts it for you. It won't.
useTransition hasn't come up by name once in this series, and the mechanism it exposes has been running underneath every single example anyway. Calling it gives you exactly two things back, an isPending flag and a startTransition function.
Every checkout example so far ran inside a , which wraps its Action in a Transition automatically. useTransition is for when there's no form to lean on. Picture a wishlist heart icon sitting on a product card, no form anywhere near it, just a click handler that needs to hit the server and report whether it's pending.
Notice that second, nested startTransition wrapping setSaved after the await. If that looks familiar, it should, it's the exact same shape as onConfirmed getting wrapped in its own startTransition back in Part 2's comment box. That's not two separate patterns you happened to see twice. React only automatically tracks synchronous work as part of a Transition. Anything after an await needs its own startTransition call to still count, whether you're inside a comment form or a heart icon with no form in sight.
