React 19's `useFormStatus` Is a Trap If You Use It Wrong
`useFormStatus` looks like the obvious way to wire pending states into forms with Server Actions. It is — until you nest it, memoize it, or try to read it from the component that owns the form.
useFormStatus is the smallest, friendliest hook React 19 shipped. It also quietly broke three of our forms in production during a routine upgrade. The failure mode wasn't a crash — it was pending spinners that never spun, submit buttons that stayed enabled, and one checkout flow that let users double-submit a payment intent.
This is a field guide to the pitfalls, written after a few painful post-mortems.
What useFormStatus actually does
The hook reads the status of the parent <form> it's rendered inside. That word — parent — is doing a lot of work. It does not read the form it is called from if that form is the same component. It reads the nearest ancestor <form> in the React tree.
Here's the canonical, correct usage:
'use client';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} aria-busy={pending}>
{pending ? 'Saving…' : 'Save'}
</button>
);
}
export function ProfileForm({ action }: { action: (fd: FormData) => Promise<void> }) {
return (
<form action={action}>
<input name="name" />
<SubmitButton />
</form>
);
}
The SubmitButton works because it's a descendant of the <form>. Move the useFormStatus() call up into ProfileForm itself and pending will always be false. That's the first trap, and the one every team hits exactly once.
Why the scoping rule exists
React needs a stable way to know which form you're asking about. Rather than inventing a form ID system or a ref API, the team chose context: <form> publishes its status, descendants subscribe. It's elegant, but it means every piece of UI that cares about pending state has to live inside the form in the tree, not just visually.
Trap 1: Reading status from the form component itself
This is the one that bit us hardest. A developer wanted to disable the whole form during submission, so they wrote:
// ❌ Broken: pending is always false here
export function ProfileForm({ action }) {
const { pending } = useFormStatus();
return (
<form action={action}>
<fieldset disabled={pending}>
<input name="name" />
<button type="submit">Save</button>
</fieldset>
</form>
);
}
No warning. No error. The hook just returns the default state forever because there is no ancestor <form> — the component renders the form, it isn't inside one.
The fix is to lift the fieldset into a child component:
function FormBody({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return <fieldset disabled={pending}>{children}</fieldset>;
}
export function ProfileForm({ action }) {
return (
<form action={action}>
<FormBody>
<input name="name" />
<button type="submit">Save</button>
</FormBody>
</form>
);
}
Annoying? Yes. But this is the model. Treat useFormStatus as a context consumer and the rule becomes obvious.
Trap 2: Nested forms you didn't know you had
HTML doesn't allow nested <form> elements, but React trees routinely nest them across component boundaries. A CommentForm dropped inside a ArticleEditForm is a nested form. Browsers will refuse to render the inner one correctly, and useFormStatus will give you whichever form the HTML parser ended up with — which is usually not what you wanted.
We caught this in a dashboard where editors could post comments on draft articles. The comment button's spinner was reading the outer article form's status. Save the article, and every comment button in the sidebar would spin.
If you need "forms" that live near each other, use buttons with formAction instead:
<form action={saveArticle}>
{/* article fields */}
<button type="submit">Save article</button>
<button type="submit" formAction={postComment}>
Post comment
</button>
</form>
Or lift the second form out of the tree entirely with a portal. Don't nest.
Trap 3: Memoization freezes the pending state
This one is subtle enough that we only found it with a React DevTools profile. A submit button wrapped in React.memo with no props will not re-render when the form transitions to pending, because its props didn't change — and useFormStatus reads from context, which memo doesn't observe for prop equality but does participate in via the normal context subscription.
In theory this is fine. In practice we hit a case where a wrapping memo on a <ButtonGroup> combined with a child useFormStatus() call produced stale UI during fast resubmissions. The hook fired, but a parent's bail-out prevented the child from committing in time for the first paint.
The rule we settled on:
Don't
memocomponents that calluseFormStatus, and don'tmemotheir parents unless you've profiled and know why.
The hook is cheap. Submit buttons don't need memoization.
Trap 4: Confusing pending with "the action resolved"
useFormStatus().pending goes false the moment the server action's promise resolves on the client. It does not wait for:
revalidatePath/revalidateTagto refetch- Suspense boundaries to resolve with the new data
useOptimisticto roll back on error
So if your button says "Saved!" when pending flips to false, you may be lying to the user. The data on screen is still the old data for another 50 – 300ms (in our experience, depending on cache hit rate and payload size).
If you need "truly done", combine useFormStatus with useActionState and gate your success UI on the returned state, not on the absence of pending:
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
type State = { ok: boolean; error?: string } | null;
export function SaveForm({ action }: { action: (prev: State, fd: FormData) => Promise<State> }) {
const [state, formAction] = useActionState(action, null);
return (
<form action={formAction}>
<input name="title" />
<Submit />
{state?.ok && <p role="status">Saved.</p>}
{state?.error && <p role="alert">{state.error}</p>}
</form>
);
}
function Submit() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button>;
}
pending drives the button; state drives the result message. Different concerns, different sources of truth.
Trap 5: Accessibility regressions from disabled buttons
Setting disabled={pending} on your submit button feels responsible — it prevents double-submits. It also removes the button from the tab order and prevents screen readers from announcing the state change in some assistive tech. For long-running actions this is a real UX bug.
Our current pattern:
<button
type="submit"
aria-disabled={pending}
aria-busy={pending}
onClick={(e) => pending && e.preventDefault()}
>
{pending ? 'Saving…' : 'Save'}
</button>
aria-disabled communicates state without removing focusability. The click handler blocks the duplicate submission. For critical paths like payments, we still use disabled and move focus to a live region — the trade-off is worth it there.
What about the form's own data attributes?
If you want to style the whole form while pending (e.g. dim it), the cleanest path we've found is a thin wrapper:
function FormShell({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return <div data-pending={pending || undefined}>{children}</div>;
}
Then in CSS: form:has([data-pending]) { opacity: 0.7; pointer-events: none; } — or scope more tightly. Keep the hook call in a small, dedicated component so it's obvious why it exists.
When not to reach for useFormStatus
If you're already using useActionState, you don't always need useFormStatus too. useActionState returns a pending tuple element in React 19 as well. Use it when:
- The pending indicator lives in the same component as the action state
- You want one source of truth for pending + result
Use useFormStatus when:
- The pending indicator is deep in the tree (nested buttons, toolbars)
- You don't want to prop-drill
pendingthrough five layers - You're building reusable form primitives for a design system
Mixing both in the same form is fine, as long as you're deliberate about which one owns which piece of UI.
Where we'd start
If you're adopting React 19 forms on an existing codebase, do three things before you ship:
- Grep for every
useFormStatus()call and confirm it lives in a descendant of a<form>, not in the form component itself. - Add a lint rule or code review checklist item: "no
memoon components callinguseFormStatus." - Audit submit buttons for
disabledvsaria-disabled— pick one pattern per app and stick to it.
And if you want a sanity check on a form-heavy App Router build, our team does this kind of review as part of our web engineering work. The hook is good. The defaults are mostly right. You just have to respect what it's actually reading from.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Why Your Next.js Middleware Is Secretly Running Twice (And How to Stop It)
A war story about a Next.js app where middleware ran twice per request, doubled our auth bill, and broke rate limiting. Here's what caused it and how we found it.
Streaming Metadata in Next.js: Why Our Twitter Cards Showed Yesterday's Title
Next.js 15 streams metadata by default so pages render faster, but social scrapers don't wait around. Here's how we tracked down stale Twitter cards and the pattern we settled on in production.
The `use` Hook Meets Suspense: What Actually Happens When You Pass a Promise to a Client Component
React 19's `use` hook lets you unwrap promises inside client components. It's elegant until a parent re-renders and your tab goes blank. Here's what's actually happening and how to not get burned.
