useFormStatus and the SubmitButton
React's useFormStatus hook, which lets a nested submit button read a form's pending state and become a reusable SubmitButton component.
In the last lesson the submit button sat right inside the form component, so reading isPending from useActionState was free: the value and the button lived in the same function. Real forms are rarely that tidy. The submit button is often a design-system <Button>, or it lives inside a shared footer that several forms reuse, and isPending is stuck up in the form’s owning component, a hop or two away from where the button renders.
This lesson gives you the tool for that gap: a way to read the form’s submit state from a nested component without threading a prop down to it. The hook is useFormStatus, and its surface is small. What matters here is the pattern more than the API itself. By the end you’ll have packaged it into a <SubmitButton> component you write once and drop into every form in the codebase, with no wiring per form. That component is the thing you walk away with, and every form you build from here on reuses it.
The prop-drilling problem isPending can’t fix
Section titled “The prop-drilling problem isPending can’t fix”Take the create-invoice form from the last lesson and complicate it the way production does. The submit button is no longer a bare <button> you control directly. It’s a shadcn <Button> , and it sits inside a <FormFooter> layout component that lines up the cancel and submit actions with consistent spacing. That footer gets reused across every form in the app, so it doesn’t know or care which form it’s inside.
Now you want the button to disable itself and show a spinner while the form submits. The only place that knows the form is submitting is the owner, which holds isPending. To get that fact down to the button, you’d have to hand isPending to <FormFooter>, which hands it to <Button>. That’s prop-drilling , and it’s worth seeing up close before reaching for the fix.
const CreateInvoiceForm = () => { const [state, formAction, isPending] = useActionState(createInvoice, null); return ( <form action={formAction}> {/* fields omitted */} <FormFooter isPending={isPending} /> </form> );};
const FormFooter = ({ isPending }: { isPending: boolean }) => { return ( <Button type="submit" disabled={isPending}> Save invoice </Button> );};The smell. isPending is a fact the owner has, but <FormFooter> doesn’t use it; it only forwards it. Every layer between the form and the button grows a prop it carries but never reads, and <FormFooter> is now coupled to a value it has no reason to know about.
const CreateInvoiceForm = () => { const [state, formAction] = useActionState(createInvoice, null); return ( <form action={formAction}> {/* fields omitted */} <FormFooter /> </form> );};
const FormFooter = () => { const { pending } = useFormStatus(); return ( <Button type="submit" disabled={pending}> Save invoice </Button> );};The fix. <FormFooter> reads pending straight from the form it’s rendered inside, with no prop and no coupling. The owner doesn’t even have to pull isPending out of useActionState anymore for the button to work. The next section explains how the footer reaches a value nobody passed it.
The two problems here are different in kind, so it’s worth naming each one. First, every intermediate component grows a prop it only forwards: <FormFooter> accepts isPending purely to pass it along, which makes it harder to read and ties it to a concern that isn’t its job. That one is a smell you could live with. The second one corners you, because you can’t always add the prop. A shadcn <Button> is a component you don’t own. Its props are fixed by the library, and “is the parent form submitting” is not one of them. The moment the button you want to control lives behind a boundary you don’t control, prop-drilling isn’t just ugly, it’s impossible.
This gives you a clear trigger. When the submit button crosses a component boundary the form doesn’t own, or sits more than one forwarding hop away, stop drilling and let the button read the form’s state itself.
How a form shares its submit state
Section titled “How a form shares its submit state”Once you see the mechanism that makes useFormStatus work, the descendant-only rule stops feeling arbitrary.
A <form> element doesn’t just collect inputs. While it’s submitting, it quietly broadcasts its in-flight state on a React context , an invisible channel that any component rendered inside that form can tune into. useFormStatus is the receiver. It doesn’t take the form as an argument or read a prop. It reaches up the tree, finds the nearest enclosing <form>, and reports whether that form is currently submitting. That’s why the button in the footer could read pending with nobody handing it down: it was listening on a channel the form was already broadcasting.
The key word is descendant . The form publishes this channel for the subtree below it, so a component has to be nested inside the <form> to receive the broadcast. This is the one rule that catches everyone, and the next diagram is built to make it stick.
renders the <form>, but is not inside it
The form broadcasts its in-flight state to everything nested inside it, and a descendant like <SubmitButton> tunes in with useFormStatus. The owning component sits outside that subtree, where it already holds the same fact as isPending from useActionState, on a separate channel.
Read the diagram from the right. The <form> is the source. Its descendants, the input and the submit button, can subscribe with useFormStatus and learn that the form is submitting. Now look left. The owning component renders the form, but it isn’t inside it, so it’s outside the subtree the form broadcasts to. The owner doesn’t need the channel anyway, because it called useActionState and already holds the same fact as isPending, directly. That’s one truth seen from two vantage points: the owner reads isPending, and everything nested inside reads pending.
The hook’s return, pending first
Section titled “The hook’s return, pending first”The hook takes no arguments and returns one object. Almost every time you reach for a single field of it, and the rest you use only occasionally.
const { pending, data, method, action } = useFormStatus();pending is the one that does the work. It’s true from the moment the form starts submitting until the action resolves, which is exactly the window where you want the button disabled and a spinner showing. Build the button around pending and you’re done. You can ignore the rest of the object until a specific UX asks for it.
data is the in-flight FormData, the values being submitted right now. It earns its place in confirmation UX: a delete button can read the in-flight data to render “Deleting INV-104…” inside its disabled state, so the user sees exactly which record is going. The project chapter’s delete confirmation reaches for it, and outside that case you won’t. method ('get' or 'post') and action (the invoked action reference) round out the object and rarely come up, so just know they exist.
One detail trips people up, the same one the last lesson flagged on purpose.
Writing the SubmitButton once
Section titled “Writing the SubmitButton once”Now the payoff arrives. Everything above exists so that this component can be written one time and reused everywhere.
'use client';
import { Loader2 } from 'lucide-react';import type { ReactNode } from 'react';import { useFormStatus } from 'react-dom';
import { Button } from '@/components/ui/button';
export const SubmitButton = ({ children }: { children: ReactNode }) => { const { pending } = useFormStatus(); return ( <Button type="submit" disabled={pending}> {pending && <Loader2 className="size-4 animate-spin motion-reduce:animate-none" />} {children} </Button> );};Walk through what each piece is doing. The 'use client' directive at the top is required, because useFormStatus is a client hook that reads runtime DOM state, so the file has to run on the client. The props are typed as { children: ReactNode }, the widest type that accepts strings, fragments, and elements alike, so <SubmitButton>Save invoice</SubmitButton> just works. Inside, useFormStatus() gives you pending, which drives two things at once: disabled={pending} stops a second click while the first is in flight, and pending && <Loader2 … /> swaps in a spinning loader icon next to the label. The pending && short-circuit is safe here because pending is a real boolean, so there’s no risk of leaking a 0 into the JSX.
Notice the motion-reduce:animate-none on the spinner. Every animation that’s visible enough to notice gets a motion-reduce: variant: for a user who’s asked their system to reduce motion, the icon still appears, it just stops spinning. The spinner is the textbook case for this, so make it a habit.
One more decision is worth naming. The button wraps shadcn’s <Button> and uses it exactly as imported, rather than reaching into the primitive to edit it. That’s deliberate. You don’t fork a design-system primitive to add a behavior; you wrap it and compose the behavior on top, at the app level. <SubmitButton> is that wrap: shadcn owns the look and the accessibility, and your component adds the form-aware behavior. Editing Button directly would tangle a one-form concern into a primitive used across the entire app.
With the component in hand, dropping it into the form is the easy part, and it’s where you see what the hook bought you.
const CreateInvoiceForm = () => { const [state, formAction, isPending] = useActionState(createInvoice, null); return ( <form action={formAction}> {/* fields omitted */} <button type="submit" disabled={isPending}> {isPending ? 'Saving…' : 'Save invoice'} </button> </form> );};Before. The button is hand-wired inside the form, reading isPending directly. That’s fine while the button is a direct child, but this exact block has to be re-pasted into every form, and it can’t move into a shared layout without dragging isPending along.
const CreateInvoiceForm = () => { const [state, formAction] = useActionState(createInvoice, null); return ( <form action={formAction}> {/* fields omitted */} <SubmitButton>Save invoice</SubmitButton> </form> );};After. One line, with no isPending for the button. The same <SubmitButton> now drops into any form with no wiring, and that’s the entire return on the hook. Write it once, reuse it forever.
That’s the whole point of the exercise: the button region went from a block you re-paste into every form to a single self-contained tag. The <SubmitButton> carries its own pending logic, reads it from whatever form it’s dropped inside, and asks for nothing in return. The cost was writing the component once, and the payoff repeats on every form you’ll ever build.
One caveat keeps you from over-reading the refactor. Extracting the button doesn’t mean isPending disappears from the form. The owner may still want it for other things, such as disabling an entire <fieldset> while the form submits, or showing a form-level spinner. Those still read isPending from useActionState. All the extraction did was stop the button from depending on it. The two hooks coexist happily, reading the same submit lifecycle from different seats.
pending versus isPending, side by side
Section titled “pending versus isPending, side by side”This is the conceptual crux, so it’s worth making sharp. useActionState().isPending and useFormStatus().pending report the exact same fact, that this form is submitting, and yet they are not interchangeable. The difference is entirely about who’s asking:
useActionState().isPendinganswers the form’s owning component, the one that called the hook and holds the action state.useFormStatus().pendinganswers a descendant rendered inside the<form>.
Here is the trap, stated plainly: call useFormStatus in the form’s own render scope, and pending is false forever. There’s no error and no warning. The spinner just never appears, and the code looks completely correct. The reason is the mechanism from earlier: the form publishes its context for the subtree below it, and the form’s own component isn’t part of that subtree. It renders the form, but it doesn’t live inside it, so the receiver is listening on a channel that, from its position, carries nothing. This is the kind of bug that costs an afternoon precisely because nothing is visibly broken.
The decision comes down to one question: which seat is this component in? Match each scenario to the hook that fits its position.
Each component below reads the form's submit state. Match it to the hook that fits where it sits relative to the `<form>`. Click an item on the left, then its match on the right. Press Check when done.
<FormFooter> layoutuseFormStatus().pending — a descendant reads it from the enclosing form<Button> whose props you can’t extenduseFormStatus().pending — read context instead of adding a prop you can’tuseActionState and holds the resultuseActionState().isPending — the owner already has it in hand<fieldset> while submittinguseActionState().isPending — the owner’s scope, outside the broadcast subtreeFeel the descendant-only rule
Section titled “Feel the descendant-only rule”Reading about the silent failure is one thing, but watching it on your screen is what makes it stick. The next exercise hands you a form that looks wired correctly but never shows its spinner, for exactly the reason above. Your job is to find the seat the hook is sitting in and move it.
Click Save and watch: the button never disables and never reads 'Saving…', even though the action takes 1.5s. The bug is the seat the hook is sitting in — useFormStatus() is called inside App, the form's own component, so it reads pending from outside the form and gets false forever. Fix it by extracting a SubmitButton child component that calls useFormStatus() and renders the button, then render <SubmitButton /> inside the <form>. Once the hook lives inside the form, pending finally flips.
Getting it working puts the whole lesson in your hands in five minutes: the hook only reports the truth when it’s called from inside the form. Move it down one level into a child, and pending finally flips.
When a plain prop is still the right call
Section titled “When a plain prop is still the right call”Don’t walk away thinking every submit button now needs useFormStatus. The real rule is narrower than that.
If the submit button is a direct child of the form component and nothing the form doesn’t own sits between them, just pass disabled={isPending} straight to it. That’s one prop and zero forwarding hops, so reaching for a context-reading hook there solves a problem you don’t have. useFormStatus earns its weight the moment the button crosses a boundary, whether a UI-library wrapper, a shared layout, or a reusable form-control, or when getting isPending there would mean drilling through a component you don’t own. That’s the threshold, and below it the prop wins.
So why does the project ship a <SubmitButton> anyway, even for the simple forms? Because every form in the codebase reuses it. The cost of the component is paid once, and after that every form gets the spinner, the disabled-while-pending behavior, and the consistent look for free, with a single tag and no per-form decision. At that scale, a shared component beats the one-prop shortcut, because consistency across twenty forms is worth more than saving a wrapper on one. The two ideas aren’t in tension: a one-off inline button is fine for a throwaway form, and the shared component is the default for a real app. We ship the component and would still inline a prop for a true one-off; the project just rarely has true one-offs.
This also answers a question that comes up the first time a page has two forms on it, a create form and an edit form side by side. Each <form> publishes its own context, so a <SubmitButton> inside the create form only ever sees the create form’s pending, and the one inside the edit form only sees the edit form’s. There’s no shared state and no collision: one <SubmitButton> per form, each minding its own, because the context is scoped per form.
The in-flight UX is a JS-only enhancement
Section titled “The in-flight UX is a JS-only enhancement”One last thing keeps the picture honest, because it’s the senior frame for everything in this chapter.
Everything <SubmitButton> does, the spinner and the disabled state, is a JavaScript enhancement layered on top of a form that already works without it. With JavaScript disabled, useFormStatus always returns pending: false, so the spinner never renders and the button never disables. But the form still submits, and the action still runs. The pending UX is a nicety, and the form’s correctness never depended on it. That’s progressive enhancement , the property that falls out of building forms the platform’s way. The next lessons keep returning to it; for now, just register that the spinner is the layer, not the foundation.
There’s a flip side to pending worth naming while we’re here. Because pending stays true for the entire duration of the action, a Server Action that runs long keeps the button spinning the whole time. That’s fine, since the user reads “submitting,” not “frozen,” as long as the action actually finishes. The thing to watch is that the action’s runtime has to be bounded. A mutation that might hang forever turns your honest spinner into a lie. The fix isn’t on the button. It’s keeping the action fast and bounded with the timeout and idempotency discipline from the Server Actions chapter, and pushing genuinely long work to a background job, which a later chapter covers. The button reports the truth; it’s the action’s job to make that truth a short one.
External resources
Section titled “External resources”The hook’s official reference is worth a bookmark, since it documents the full return object and the edge cases this lesson didn’t dwell on.
Official reference: the full return object and the descendant-only rule, with a live pitfall demo.
Where the submit-state context comes from, and why the form keeps working with JavaScript off.
The same SubmitButton extraction, plus pending UI and progressive enhancement in the App Router.
Dave Bitter on why a form built the platform's way works before any JavaScript loads.