useFormStatus and the SubmitButton
React's useFormStatus hook lets a nested submit button read a form's pending state, packaged as a reusable SubmitButton.
In the last lesson the submit button sat 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 sits in a shared footer that several forms reuse, a hop or two away from the form’s owning component where isPending lives.
useFormStatus closes that gap: it reads the form’s submit state from a nested component without threading a prop down to it.
By the end you’ll have packaged it into a <SubmitButton> you write once and drop into every form, with no per-form wiring.
When isPending can’t reach the submit button
Section titled “When isPending can’t reach the submit button”Take the create-invoice form from the last lesson and make it look like production code.
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 aligns the cancel and submit actions with consistent spacing.
That footer is reused across every form in the app, so it doesn’t know which form it’s inside.
Now you want the button to disable itself and show a spinner while the form submits.
Only the owner knows the form is submitting, because only the owner holds isPending.
To get that fact to the button, you’d hand isPending to <FormFooter>, which hands it to <Button>.
That’s prop-drilling .
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. <FormFooter> doesn’t use isPending; it only forwards it to the button, and 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 no longer pulls isPending out of useActionState at all. The next section explains how the footer reaches a value nobody passed it.
The two problems are different in kind.
First, every intermediate component grows a prop it only forwards, which is harder to read and ties the component to a concern that isn’t its job; that one is a smell you could live with.
The second one corners you: 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” isn’t one of them.
Once the button lives behind a boundary you don’t control, prop-drilling isn’t just ugly, it’s impossible.
So you have 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”A <form> element doesn’t just collect inputs.
While it’s submitting, it 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 takes no argument and reads no prop, it reaches up the tree, finds the nearest enclosing <form>, and reports whether that form is submitting.
That is how the button in the footer read pending with nobody handing it down.
The key word is descendant .
The form broadcasts to the subtree below it, so a component has to be nested inside the <form> to receive it.
This is the one rule that catches everyone.
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 renders the form but sits outside that subtree, where it already holds the same fact as isPending from useActionState.
It is one truth seen from two vantage points.
The owner called useActionState, so it reads isPending directly; every descendant nested inside the form reads pending off the broadcast instead.
The hook’s return, pending first
Section titled “The hook’s return, pending first”The hook takes no arguments and returns one object. One field does nearly all the work; the rest come up 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, exactly the window where you want the button disabled and a spinner showing.
Build the button around pending and 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 it to render “Deleting INV-104…” inside its disabled state, so the user sees which record is going.
method ('get' or 'post') and action (the invoked action reference) round out the object and rarely come up, so just know they exist.
Writing the SubmitButton once
Section titled “Writing the SubmitButton once”This is the payoff: a button you write once and drop into any form.
'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> );};The 'use client' directive is required: useFormStatus is a client hook, so the file has to run on the client.
Typing children as ReactNode accepts strings, fragments, and elements alike, so <SubmitButton>Save invoice</SubmitButton> works.
The pending flag then drives two things at once: disabled={pending} blocks a second click while the first is in flight, and pending && <Loader2 … /> swaps in a spinning loader beside the label.
The spinner carries motion-reduce:animate-none.
Make this a reflex for every visible animation: a user who has asked their system to reduce motion still sees the icon, it just holds still instead of spinning.
Wrap the primitive, don’t fork it.
<SubmitButton> uses shadcn’s <Button> exactly as imported and composes the form-aware behavior on top, at the app level.
shadcn keeps owning the look and accessibility; editing Button directly would bury a one-form concern inside a primitive used across the whole app.
Dropping it into the form is 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 works while it’s a direct child, but the block has to be re-pasted into every form and 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, no isPending for the button. The same <SubmitButton> drops into any form with no wiring. Write it once, reuse it forever.
Extracting the button does not delete isPending from the form.
The owner may still want it to disable a whole <fieldset> during submit or to show a form-level spinner, and those keep reading isPending from useActionState.
The extraction only stopped the button from depending on it; the two hooks coexist, reading the same submit lifecycle from different seats.
pending versus isPending: owner versus descendant
Section titled “pending versus isPending: owner versus descendant”useActionState().isPending and useFormStatus().pending report the exact same fact, that this form is submitting, 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: call useFormStatus in the form’s own render scope, and pending is false forever, with no error and no warning.
The form publishes its context for the subtree below it, and the form’s own component renders the form without living inside it.
The spinner never appears, the code looks correct, and the bug stays invisible.
Move the hook inside the form
Section titled “Move the hook inside the form”The next exercise hands you a form that looks wired correctly but never shows its spinner.
Find where useFormStatus is called 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.
useFormStatus reports pending only when it runs inside the form.
Once you move the call into a child component rendered there, the button reacts.
When disabled={isPending} is enough
Section titled “When disabled={isPending} is enough”If the submit button is a direct child of the form component, with nothing the form doesn’t own between them, pass disabled={isPending} straight to it.
That’s one prop and zero forwarding hops, so a context-reading hook there solves a problem you don’t have.
useFormStatus earns its weight once the button crosses a boundary, a UI-library wrapper, a shared layout, or a reusable control, or when reading isPending would mean drilling through a component you don’t own.
So why does the project ship a <SubmitButton> even for simple forms?
Because every form reuses it.
You pay for the component once, and after that every form gets the spinner, the disabled-while-pending behavior, and a consistent look from a single tag.
Across twenty forms, that consistency is worth more than saving a wrapper on one.
A true one-off would still take an inline prop; the project just rarely has true one-offs.
This also settles what happens the first time a page holds two forms, a create form and an edit form side by side.
Each <form> publishes its own context, so the <SubmitButton> in the create form sees only the create form’s pending, and the one in the edit form sees only its own.
The context is scoped per form, so there’s no shared state and no collision.
The in-flight UX is a JS-only enhancement
Section titled “The in-flight UX is a JS-only enhancement”Everything <SubmitButton> does, the spinner and the disabled state, is a JavaScript layer on top of a form that already works without it.
With JavaScript disabled, useFormStatus returns pending: false, so nothing spins and nothing disables, but the form still submits and the action still runs.
That’s progressive enhancement , and later lessons keep returning to it; for now, register that the spinner is the layer, not the foundation.
There’s a flip side worth naming.
Because pending stays true for the entire action, a Server Action that runs long keeps the button spinning the whole time, which is fine as long as the action’s runtime is bounded.
A mutation that might hang forever turns an honest spinner into a lie.
Keep the action fast and bounded, and push genuinely long work to a background job.
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.