Part 1 was about waiting well. Part 2 was about not waiting at all. This one is about a value I needed in a component that never created it, and about the ten minutes I lost putting the hook in the one place it can't work.
If you've read Part 1 on useActionState and Part 2 on useOptimistic, both of those hooks answer a question from inside the component that called them. That was never a problem for a two-field newsletter form. It became one the day I split an order form into separate pieces, payment details in one file, the place-order button in another, and both of them needed to know the same thing: is this thing submitting right now.
The setup that made prop drilling look reasonable
Here's roughly what the form looked like before I touched useFormStatus at all.
import { useActionState } from "react";
function PaymentFields({ disabled }) {
return (
<fieldset disabled={disabled}>
<input name="cardNumber" placeholder="Card number" />
<input name="cardExpiry" placeholder="MM/YY" />
</fieldset>
);
}
function PlaceOrderButton({ pending }) {
return (
<button type="submit" disabled={pending}>
{pending ? "Placing order..." : "Place order"}
</button>
);
}
function OrderForm() {
const [state, formAction, isPending] = useActionState(placeOrder, {
message: "",
});
return (
<form action={formAction}>
<PaymentFields disabled={isPending} />
<PlaceOrderButton pending={isPending} />
</form>
);
}
Nothing about this is broken. It works. But isPending lives in OrderForm, and both children only exist to receive it and hand it to a disabled attribute. Add a shipping section, an order-summary sidebar, a coupon field that should lock during submission, and every one of them needs the same prop threaded through, whether or not anything else about them changes. I'd read that useFormStatus was built to skip exactly this, so I went looking for where to put it.
Where I put it first, and why it did nothing
My first instinct was to call the hook right where isPending used to live, in OrderForm itself, treating it like a drop-in replacement.
import { useFormStatus } from "react-dom";
function OrderForm() {
const { pending } = useFormStatus(); // stays false forever
return (
<form action={placeOrder}>
<PaymentFields disabled={pending} />
<PlaceOrderButton pending={pending} />
</form>
);
}
I clicked "Place order." Nothing disabled. No console warning, no red error overlay, just a button sitting there fully clickable while the network tab showed a request in flight. My first guess was that placeOrder wasn't actually async, or that I'd broken something in the action itself. I spent a few minutes staring at the action function before I even looked back at the hook.
The actual answer is simpler and, once you see it, a little obvious. useFormStatus doesn't read state that OrderForm owns. It reads the status of the parent <form>, and at the moment that hook call runs, OrderForm is still deciding what to render. The <form> tag doesn't exist as anyone's parent yet, it's the thing being built. There's no ancestor for the hook to find, so it falls back to its default, unpending shape, and it stays that way no matter how long the real submission takes.
The rule underneath that: the component calling useFormStatus has to be a descendant of the form it wants to read, never the component rendering the form itself. Fixing it meant pulling the hook out of OrderForm and putting it somewhere further down the tree.
import { useFormStatus } from "react-dom";
function PaymentFields() {
const { pending } = useFormStatus();
return (
<fieldset disabled={pending}>
<input name="cardNumber" placeholder="Card number" />
<input name="cardExpiry" placeholder="MM/YY" />
</fieldset>
);
}
function PlaceOrderButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Placing order..." : "Place order"}
</button>
);
}
function OrderForm() {
return (
<form action={placeOrder}>
<PaymentFields />
<PlaceOrderButton />
</form>
);
}
Same markup, same behavior at a glance, and suddenly pending flips correctly. Neither child receives anything from OrderForm anymore. Both just ask their parent form what's going on and get told the truth.
One detail that's easy to miss on the way there: this hook comes from react-dom, not react. useActionState and useOptimistic both live in react, so if you're moving through this series in order, typing import { useFormStatus } from "react" is a genuinely easy slip, and it fails in a way that doesn't tell you what went wrong either.
The part I didn't expect: it's watching more than a boolean
Once pending worked, I assumed that was the whole hook. It isn't. Calling it gives you an object with three more properties riding along, and I only found a real use for them once I stopped treating useFormStatus as a fancy boolean.
const { pending, data, method, action } = useFormStatus();
data is the FormData currently being submitted, available only while something is in flight. I used it to make the button say something more specific than a generic spinner label:
function PlaceOrderButton() {
const { pending, data } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? `Placing order for ${data.get("email")}...` : "Place order"}
</button>
);
}
data is null the rest of the time, so that .get("email") call only runs safely because it's tucked inside the pending branch of the ternary. Pull it out and call it unconditionally, and the component throws the first time it renders idle.
method is where I got tripped up briefly, because I assumed it would mirror whatever method attribute I'd have put on the form. It doesn't work that way once you're using a function-based action. Any form using the action prop with a function, which is the entire reason this hook has something to track in the first place, submits as 'post' regardless of anything else you configure. You'd only see 'get' here on a form using a plain URL string as its action, or no action prop at all, and the docs only commit to one thing about that case: action itself comes back null. They stay quiet on pending and data, and since only function-based actions actually run through React's Transition tracking, that's not a combination I'd build anything around.
action is a reference back to whatever function you passed to the form's action prop. It's null for a form with no function-based action, and it's also null if there's no parent form at all, which means a falsy value here doesn't automatically tell you which of those two is true. I hit this once trying to conditionally render something based on whether action existed, and had to remind myself both cases collapse to the same falsy check.
The reused-component trick I didn't plan for, and the one I got burned by
The nicest surprise came later, almost by accident. PlaceOrderButton wasn't just used on the checkout page. It got reused, unmodified, on a "save draft order" form elsewhere in the app. I expected to need some kind of context or ID to tell it which form it belonged to. I didn't. useFormStatus resolves the nearest ancestor form independently everywhere it's rendered, so the same component read the correct pending state on both forms without a single line of extra wiring. That's not an edge case worth working around, it's a real reason to build small, reusable status-aware components in the first place.
The edge case that did surprise me went the other direction. Somewhere in an admin panel, a designer had wired a button outside its form using the HTML form="orderForm" attribute instead of physically nesting it. Pure HTML, no React opinion needed, and it submits the form fine. useFormStatus inside that button read as permanently not-pending, even while the form it was technically attached to was mid-submission. The hook walks the component tree, not the DOM-level form attribute association, so as far as it's concerned, that button was never a child of anything. Worth knowing before it surprises you in a codebase that uses this attribute anywhere.
Where I stopped reaching for it
Not every form needed this. On a two-field signup form with a single button in the same component, useActionState's own isPending, read directly where the hook was called, did the job without adding a second hook and a second rule to remember. useFormStatus earns its place once a form's parts genuinely live in separate components, not before.
And it only ever tells you about a submission already underway. useFormStatus isn't a validation API, it tells you what the form is doing once submission has started, not whether that submission should have been allowed to start in the first place. That decision still belongs upstream, in the action function or wherever else you're handling the submit.
Where useActionState, useOptimistic, and useFormStatus actually sit
Three parts into this series and the boundaries finally made sense to me as one picture instead of three separate hooks to memorize. useActionState owns a result and reacts to it, from wherever it was called. useOptimistic shows the finished UI before the server has actually agreed to it. useFormStatus answers one question, is this form pending, from any component underneath it, with nothing passed down to make that possible.
The distinction that finally made it stick for me: useActionState's isPending follows an Action, specifically the one dispatched through that hook call. useFormStatus's pending follows a form, whichever <form> happens to be its nearest parent, regardless of which component or which action put it there. That's why the same PlaceOrderButton worked unmodified on two different forms with two different actions later on. It was never watching an action. It was watching whatever form it happened to be sitting inside.
They stack without fighting each other. The order form I started with ended up using two of the three, useActionState in the parent owning the result, useFormStatus in every child section reading the same pending state independently.
I wrote the full breakdown on my site, including the complete four-section checkout example combining both hooks, the exact difference between this and useActionState's own isPending, and a side-by-side table for picking the right one: React 19 useFormStatus Explained.
If you want to test what you just learned, I also put together a short React 19 useFormStatus Quiz.
If you've built a form where the submit button lives three components away from the one managing the submission, it's worth checking where you actually called the hook before you assume something's broken. I know that's exactly where I lost ten minutes I didn't need to.
Top comments (13)
The form-vs-DOM-tree distinction for useFormStatus is the part I'd have gotten wrong. Good catch on the react-dom import too, that's exactly the kind of thing that fails silently instead of throwing. Curious whether you've tried this pattern with Next.js Server Actions specifically, or kept the series to plain React so far.
Glad you caught the
react-domimport, Tarun. That one fails silently, which is the worst kind of bug because you start questioning everything except the import line. 🙂I’ve kept this series framework-agnostic so far. I have written about Next.js Server Actions elsewhere, mostly auth stuff and cache invalidation, though I haven’t specifically paired those posts with
useFormStatus.My expectation is that the
useFormStatuspart works the same way with a Server Action, since the hook is reading the pending state of its parent form rather than caring about the implementation behind the form’saction. But I haven’t demonstrated that combination in one of my posts yet.The auth side is here: Next.js 16 Server Actions Security: The Auth Check Most Developers Miss.
I also covered optimistic UI with Next.js Server Actions here: My Next.js 16 Optimistic UI Looked Perfect. Then Someone Clicked It Five Times Fast.
For this series, I deliberately kept the examples in plain React so the hook mechanics aren’t buried under framework-specific auth and cache APIs. The
PlaceOrderButtonpattern here is the part I’d be interested in trying with a Server Action next.Appreciate the detailed reply. Makes sense, if useFormStatus is just reading the form's pending state it shouldn't care what's calling the action. Gonna check out your auth check post, that's exactly the kind of Server Actions gotcha I keep hearing about. If you do try the PlaceOrderButton pattern with a Server Action, curious whether pending from useFormStatus and any transition state from useTransition ever end up fighting each other.
Thanks, Tarun. Good question. I cover both cases in my Server Actions tutorial. For a form action I use
useFormStatusin the child button, and for anything outside a form like a delete button I useuseTransition. I haven't written the case where both get used on the same interaction, so I don't have a tested answer for that part yet.My expectation is that manually wrapping a form action in
startTransitioncould make the pending states confusing, since you'd be introducing another transition state around the same interaction instead of just letting the form action handle it. I haven't tested that combination yet, though. If I build out the Server Action version ofPlaceOrderButton, I'll try that case on purpose and see what actually happens rather than guessing.That makes sense, the pending state from startTransition would basically be a second source of truth competing with the one useFormStatus already reads off the form. If you do end up testing it, I'd guess the safer pattern is to only wrap the non-form parts of the click handler in startTransition and just let the form action own its own pending state, but that's a guess too. Would genuinely read that follow up post if you write it.
Yeah, that's the case I'd want to test rather than guess about. The split you're describing, where the form action owns its own pending state and
startTransitiononly wraps the non-form parts, makes sense on paper, but I'd want to see what actually happens when they overlap before recommending it. If I do the Server Action version, I'll definitely test that case on purpose. 🙂Makes sense, testing it directly is the only real way to know with overlap cases like this. Following the Server Action version when you get to it, that's exactly the kind of scenario that looks fine in isolation and then gets weird once both pending states are live at the same time.
Nice debugging of
useFormStatus! You'll cover all the functions in Next.js soon! 👍Thank you so much! 😊 More React 19 and Next.js content is definitely coming, along with plenty of other dev topics!
Now I'm wondering about the mirror of your form attribute case. A button rendered through createPortal sits outside the form in the DOM but inside it in the component tree, so it should keep reading pending correctly. Did you happen to try that one?
That’s a really good edge case. useFormStatus follows the React tree rather than the DOM tree, so a button rendered through createPortal can still read the parent form’s pending state even though it’s mounted elsewhere in the DOM. I didn’t cover portals in this post, but that’s definitely a useful nuance to keep in mind.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.