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.
The word I kept using and never defined
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.
async function submitFeedback(previousState, formData) {
const message = formData.get("message");
await saveFeedback(message);
return { sent: true };
}
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.
Why any of this needed inventing
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.
<form action={fn}>, and the three things nobody tells you change
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.
function OldForm() {
function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
// run the request, manage loading and error state yourself
}
return <form onSubmit={handleSubmit}>{/* ... */}</form>;
}
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.
Sync Actions exist. They're just not interesting
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.
function resetSearch(previousState, formData) {
return { query: "" };
}
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, <form action={fn}>, and useTransition all accept async functions directly instead of you managing the async boundary by hand.
Client Actions and Server Functions are not the same word twice
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.
"use server";
export async function submitFeedback(formData) {
const message = formData.get("message");
await db.feedback.create({ message });
}
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.
The fourth door: useTransition
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 <form>, 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.
import { useTransition, useState } from "react";
function WishlistButton({ productId, isSaved, toggleWishlist }) {
const [isPending, startTransition] = useTransition();
const [saved, setSaved] = useState(isSaved);
function handleClick() {
startTransition(async () => {
setSaved(!saved);
const result = await toggleWishlist(productId);
startTransition(() => {
setSaved(result.saved);
});
});
}
return (
<button onClick={handleClick} disabled={isPending} aria-pressed={saved}>
{saved ? "♥ Saved" : "♡ Save"}
</button>
);
}
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.
Notice too that the wishlist button above reaches for plain useState, not useOptimistic. That's not an oversight. This example exists to isolate what useTransition gives you entirely on its own, before any of the other three hooks get involved. In a real app you'd almost certainly reach for useOptimistic here instead, the exact instant-feedback job it was built for back in Part 2. Showing it with bare useState first is the only way to actually see what useTransition alone is doing.
Here's the part that's easy to miss and expensive to find out the hard way. Click that heart, unclick it, click it again fast, and a raw useTransition call gives you no guarantee about which result lands last. React says so directly: Actions inside a Transition don't guarantee execution order on their own. What's easy to assume, and what I nearly wrote into this section before checking, is that <form action={fn}> is just as exposed to that same problem as a bare useTransition call. It isn't. React's own docs group <form> actions together with useActionState as the two built-in ways that already handle ordering for you. It's raw useTransition, used on its own with nothing else wrapping it, that gets no such guarantee, the wishlist button above included. If you've been assuming that guarantee comes from Actions in general, it doesn't. It's specifically what useActionState and <form> actions add on top of the raw mechanism.
Where the four actually sit next to each other
| You need to... | Reach for |
|---|---|
| Own a form's result and pending state in one place | useActionState |
| Show the finished UI before the server has confirmed it | useOptimistic |
| Read a form's pending state from a component that isn't managing it | useFormStatus |
| Run an Action outside a form and track its pending state | useTransition |
They were never four competing answers to "which hook should I learn." They're four answers to four different questions that happen to share one mechanism underneath. The checkout form from Part 3 already proved it by combining two of them across four separate components, useActionState owning the submission, useFormStatus letting PlaceOrderButton read pending state with nothing passed down. Here's that same order form with a third hook layered on, a promo code that discounts the total the moment the form is submitted with it, before the server has confirmed anything.
import { useActionState, useOptimistic } from "react";
import { useFormStatus } from "react-dom";
async function placeOrder(previousState, formData) {
const result = await submitOrder(formData);
return { orderId: result.id, error: null };
}
function PlaceOrderButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Placing order..." : "Place order"}
</button>
);
}
function OrderForm({ cartTotal }) {
const [state, formAction] = useActionState(placeOrder, {
orderId: null,
error: null,
});
const [displayTotal, applyDiscount] = useOptimistic(
cartTotal,
(current, discount) => current - discount,
);
function handleSubmit(formData) {
if (formData.get("promoCode") === "SAVE10") {
applyDiscount(10);
}
formAction(formData);
}
return (
<form action={handleSubmit}>
<p>Total: ${displayTotal.toFixed(2)}</p>
<input name="promoCode" placeholder="Promo code" />
<PaymentFields />
<PlaceOrderButton />
</form>
);
}
useActionState still owns the real submission the same way it did in Part 3. useOptimistic shows the discounted total the moment the form submits with SAVE10 in the field, no waiting on submitOrder to confirm anything first, and it falls back on its own to whatever cartTotal currently is once the Transition settles. That fallback detail matters more than it looks. This trimmed-down version never actually updates cartTotal after submitOrder resolves, so the discount would vanish the instant the Action finishes, same trap Part 2 walked through with onConfirmed. A real version needs the parent to fold the confirmed, discounted total back into cartTotal once the order actually goes through, or the optimistic value has nothing real to settle into. useFormStatus still lets PlaceOrderButton disable itself with nothing passed down from OrderForm. Three hooks, one form, none of them stepping on the other's job.
The mistake I nearly baked into this whole series
Here's the trap I think this series set without meaning to. Teach one hook per post, three posts in a row, and a careful reader could walk away thinking the real task was picking a favorite, useActionState or useOptimistic or useFormStatus, as if only one of them belongs in a given form. That was never true. Composition was always the point. A real form rarely reaches for exactly one of these. It reaches for whichever two or three actually solve the piece of the problem sitting in front of it, and none of them are competing for the same job.
I built this series backwards on purpose, honestly, three concrete bugs before the concept underneath them, because I think a concept sticks harder once you've already felt the shape of the problem it solves. But it only works if the concept actually shows up eventually. This is that part.
If you want the deeper cut, sync versus async Actions in more detail, the full Server Functions section, and a proper FAQ, I wrote the complete version on my site: React 19 Actions Explained.
If you'd rather test what actually stuck first, I also built a 15-question quiz covering the same four APIs: React 19 Actions Quiz.
So here's the real question, the one I'd genuinely like an answer to. Which of these four have you already been using without knowing you were touching an Action? A <form action={fn}> you wired up because it looked simpler than onSubmit, a startTransition call you copied from somewhere without reading what it does, a button that felt laggy until you wrapped it in useTransition and never asked why that fixed it. Drop it below. I'm curious how many of us have been using this mechanism for months without a name for it.
Top comments (19)
So, "action" is the mother and the four "use" brothers. This is so complicated. Nice catch as always! 😄
Thanks! 😊 That family analogy actually fits surprisingly well. The fun part is seeing how these pieces start making more sense once you see the Action model underneath them.
The note that
useOptimisticis the odd one out — a setter you call from inside an Action rather than a doorway into becoming one — is the kind of invariant that almost can't be expressed in a type signature, so it lives entirely in docs and reviewer intuition until someone calls it from the wrong scope and gets silent misbehavior. I've been running into the structural version of this problem in static analysis: ESLint rules that need to flag async patterns have to reason about call sites, not just function bodies, and that's exactly the same challenge you're describing — the function is neutral, the context is what promotes it. The four-doorway framing makes that explicit in a way I hadn't seen stated cleanly before. Wondering whether you think a lint rule could realistically enforce "don't call useOptimistic outside a Transition" or whether that's always going to be runtime territory.Thanks, Ofri. That's a really precise way to put it: the function is neutral, the context is what promotes it.
On the lint rule, I think you could catch some of the obvious misuse, but probably not enforce the invariant completely. A rule can reason about where the optimistic setter is called and flag cases where there's clearly no Transition context. The harder cases are when that context is indirect or the setter is passed through another function. At that point, the rule has to reason about the call chain and control flow rather than just the function body.
So I'd see linting as a useful guardrail, not the source of truth.
useOptimisticdoesn't create the Action context itself. Its setter assumes you're already inside one, which is a runtime relationship that isn't really expressible in the type signature.Great breakdown. The ordering section especially caught my attention.
I’ve been working on form/POST semantics in a deterministic application runtime, and it made me think about where the responsibility for an “Action” should end.
pending, optimistic updates, and disabling submission are great UI concerns, but they don’t guarantee correctness once the request crosses the server boundary. Duplicate submissions, retries, concurrent requests, and out-of-order execution still need idempotency, transaction and concurrency semantics on the backend.
So I’ve started thinking of the form as just an adapter to an Action rather than the owner of it. The same Action should ideally be callable from a form, API, job, CLI, etc., while keeping the same validation and execution guarantees.
Your explanation of Actions as the shared mechanism underneath these APIs made that separation much clearer. Great article.
Thanks, Mustafa. I think that UI/server boundary is exactly the interesting part here.
Pending, optimistic updates, and disabling submission can make the UI behave correctly while an operation is in flight, but they don't make the operation itself safe against retries, duplicate submissions, or concurrent execution. Those guarantees have to come from the server-side operation and its contract.
And I like your point about treating the form as an adapter. Once you separate the React coordination from the underlying operation, the same operation can be reached from a form, API, job, or CLI without making those callers responsible for its correctness.
That's probably the boundary your comment makes especially clear: React can coordinate the UI around an Action, but it can't define what "correctly executing this operation twice" means. Whether duplicate execution is safe, rejected, or needs to be serialized is part of the server-side contract.
I like the distinction between the React Action and the underlying operation. “Operation” is definitely the cleaner term here.
It keeps the boundary clear: React coordinates the interaction, while the operation owns correctness.
That separation becomes especially valuable once the same operation has multiple entry points. Thanks for sharpening that distinction.
Exactly. That distinction became much clearer to me once I separated the React-facing coordination from the operation itself. “Operation” also feels like the better term because it doesn't tie the underlying work to any particular entry point. Glad that framing resonated.
"The same Action should ideally be callable from a form, API, job, CLI, etc" - I think that hits the nail on the head - it shouldn't matter what kind of "client" invokes the backend - the end result should be the same ...
Exactly, Leob. I think the important distinction is that the business operation should stay the same regardless of who invokes it, while the React-facing Action can be the adapter around that operation.
Even within React, the calling convention changes a bit. A form gives the Action
FormData, whileuseActionStateadds the previous state as the first argument. So I'd keep the actual business operation underneath that boundary and let the Action handle the React-specific coordination.That's probably the next layer I only hinted at in the post: the Action can be a thin adapter around the operation, rather than making the Action itself the business-logic boundary.
Right ... guru level stuff! Did you turn any of this into an open source component or hook?
Haha, thanks Leob! 😊 Not yet. The series was mainly about putting the model into words and making the boundaries clear. I haven't turned it into an open-source hook or component yet, but the adapter/operation split feels like it could make a pretty useful reusable pattern.
This was a really helpful way to tie everything together. I especially liked the explanation of why
useTransitiondoesn’t handle ordering the same wayuseActionStateand form Actions do. It makes React 19 Actions much easier to understand as one concept instead of a bunch of unrelated hooks.Thanks so much! 😊 I’m really glad that came through. The ordering distinction with
useTransitionwas important to include because the APIs can share the same Action model without providing the same guarantees. Once that clicks, the four stop looking like unrelated hooks and start feeling like one thing.Very well explained, and didactically (first lay out the problems and let the reader fully "grok" them - only later on the answers are laid out in full) this is probably the right way to structure it ...
Thanks, Leob! ☺️ That was exactly the bet with this one. I wanted readers to feel the problems first and only then put a name to the thing underneath them. By the time I finally got to “Action,” the difference between what
useActionStategives you and whatuseTransitiongives you should already feel pretty obvious. Glad that came through.Shubhra, I loved how you explained the four hooks as different answers to different problems. It made it much easier for me to understand why they are not really competing with each other 😀
Thank you so much, Hemapriya! 😊 That was exactly what I wanted to make clear with this post.
useActionStateanduseTransition, for example, are solving different problems even though they’re both using the same Action mechanism. Once you see that, the four APIs stop feeling like competing hooks and start making a lot more sense together.Some comments may only be visible to logged-in visitors. Sign in to view all comments.