React 19 useFormStatus pending is always false: the hook must live inside the form
We moved a checkout form to React 19 form actions and used useFormStatus to disable the submit button while submitting. The button never disabled and customers double-submitted orders. useFormStatus only reads the status of a parent form, and we called it in the component that rendered the form.
We rebuilt the checkout form of a Next.js app with React 19's form actions: the <form> element's
action prop takes an async function, and React handles the submission as a transition. To prevent
double submissions, the submit button should be disabled while the action runs, and useFormStatus gives
exactly that: a pending flag.
After release, the order service logged duplicate orders, created a few hundred milliseconds apart with identical carts. The button was never disabled.
The component
'use client';
import { useFormStatus } from 'react-dom';
import { placeOrder } from './actions';
export function CheckoutForm({ cartId }: { cartId: string }) {
const { pending } = useFormStatus();
return (
<form action={placeOrder}>
<input type="hidden" name="cartId" value={cartId} />
<AddressFields />
<button type="submit" disabled={pending}>
{pending ? 'Placing order...' : 'Place order'}
</button>
</form>
);
}
Observing pending
We logged pending on every render and submitted the form with network throttling enabled, so the action
took several seconds:
render pending=false
(click)
render pending=false
render pending=false
(action resolves)
render pending=false
Always false. Meanwhile the action was clearly running: the request was in flight in the network panel.
What we ruled out
- The action not being treated as a form action. The request was sent by React's form action
handling, not by a manual
onSubmit. - Server component restrictions. The component had
'use client'; hooks were allowed. - Stale React version.
reactandreact-domwere both 19.1.
Root cause
useFormStatus returns the status of the parent <form> of the component that calls it, as if it
were reading a context provided by the form. Our component called the hook and then rendered the form
itself, so there was no parent form for the hook to observe, and it always returned the idle status
(pending: false).
How useFormStatus works
React provides form status to the components rendered inside a <form> that has an action. Calling
useFormStatus() in such a component returns:
| Field | Meaning |
|---|---|
pending |
true while the parent form's action is running |
data |
The FormData being submitted |
method |
The form's method |
action |
The function or URL passed to action |
The documentation is explicit: it only returns status for a parent form, not for any form rendered in the
same component or in children. This is easy to miss because many examples put the hook in a small
SubmitButton component without explaining why.
useActionState as an alternative
useActionState wraps an action and returns its last result, a wrapped action to pass to the form, and an
isPending flag. Because it is called in the component that owns the action, it works in the same
component that renders the form.
Who is affected
Anyone adopting React 19 form actions who follows the intuition that a hook in a component describes that
component's own form. It also happens when a submit button is rendered outside the <form> element and
associated with it through the form attribute: useFormStatus follows the React tree, not the HTML
form attribute, so such a button never sees the pending state either. Forms submitted with onSubmit
instead of action do not provide a status at all.
Why it fails silently
The hook returns a valid status object with pending: false rather than throwing when there is no parent
form. That is reasonable, because the same component might be rendered inside a form elsewhere, but it
means no error or warning points at the mistake.
Environment
| Component | Version / value |
|---|---|
| React | 19.1 |
| Framework | Next.js 15, App Router, server action placeOrder |
| Form | Client component rendering the <form> |
| Symptom | Button never disabled; duplicate orders on double click |
Option 1: move the hook into a child component
'use client';
import { useFormStatus } from 'react-dom';
import { placeOrder } from './actions';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} aria-disabled={pending}>
{pending ? 'Placing order...' : 'Place order'}
</button>
);
}
export function CheckoutForm({ cartId }: { cartId: string }) {
return (
<form action={placeOrder}>
<input type="hidden" name="cartId" value={cartId} />
<AddressFields />
<SubmitButton />
</form>
);
}
SubmitButton is rendered inside the form, so its useFormStatus call observes the form's action.
Option 2: useActionState in the form component
When the form also needs the action's result, for example validation errors from the server,
useActionState covers both:
'use client';
import { useActionState } from 'react';
import { placeOrder } from './actions';
type State = { error?: string; orderId?: string };
export function CheckoutForm({ cartId }: { cartId: string }) {
const [state, formAction, isPending] = useActionState<State, FormData>(placeOrder, {});
return (
<form action={formAction}>
<input type="hidden" name="cartId" value={cartId} />
<AddressFields />
{state.error && <p role="alert">{state.error}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Placing order...' : 'Place order'}
</button>
</form>
);
}
With useActionState, the server action's signature becomes (prevState, formData) => newState, so the
action needed a small change:
'use server';
export async function placeOrder(prev: State, formData: FormData): Promise<State> {
const cartId = String(formData.get('cartId'));
const result = await orders.place(cartId, idempotencyKeyFor(cartId));
return result.ok ? { orderId: result.id } : { error: result.message };
}
We chose option 2 for checkout, because it also surfaced server errors in the form, and option 1 for simpler forms elsewhere.
Server-side protection against duplicates
Disabling a button is a usability measure, not a guarantee: users can submit from two tabs, retry on a slow network, or call the endpoint directly. The order service now requires an idempotency key derived from the cart, and a second request with the same key returns the first order instead of creating a new one. That is what actually stops duplicate orders.
Showing progress beyond the button
With the status available inside the form, other children can use it too. A small FormProgress
component inside the form shows a spinner and a "Please do not close this page" message while pending
is true, and data from useFormStatus lets it display which cart is being submitted. Both are plain
children of the form, so they read the same status without props being passed around.
Alternatives and trade-offs
- Manual
onSubmitwith local state for pending. Works, but loses progressive enhancement and the integration with transitions that form actions provide. useTransitionaround a custom submit handler, which givesisPendingfor non-form actions.
How to test the fix
A test with React Testing Library and a delayed action:
test('disables submit while the action is pending', async () => {
let resolve!: () => void;
const slowAction = () => new Promise<void>(r => (resolve = r));
render(<form action={slowAction}><SubmitButton /></form>);
await user.click(screen.getByRole('button', { name: 'Place order' }));
expect(screen.getByRole('button')).toBeDisabled();
await act(async () => resolve());
expect(screen.getByRole('button')).toBeEnabled();
});
Confirming in production
After the release, the button showed "Placing order..." and was disabled during submission. Duplicate order creation dropped to zero, and the idempotency key logs showed a handful of second submissions per day from other paths, all correctly returning the existing order.
What users noticed
Support tickets about duplicate charges stopped within days. Session recordings showed customers no longer clicking the button several times; the visible "Placing order..." state was enough to make them wait.
Regression guard
- A lint rule in our codebase warns when
useFormStatusis called in a component that also renders a<form>element. - The idempotency key on order creation is covered by an API test that sends the same request twice.
Security implications
None for confidentiality. Duplicate submissions are an integrity problem: customers were charged twice in a few cases before refunds. Server-side idempotency is the control; client-side pending state only improves the experience.
Prevention checklist
- Call
useFormStatusonly in components rendered inside the form. - Use
useActionStatewhen the form component needs pending state and the action result. - Never rely on disabled buttons to prevent duplicate side effects.
- Make order and payment endpoints idempotent.
- Test pending behaviour with a slow action.
References
- React, useFormStatus: https://react.dev/reference/react-dom/hooks/useFormStatus
- React, useActionState: https://react.dev/reference/react/useActionState
- React, form component and actions: https://react.dev/reference/react-dom/components/form
- Next.js, Server Actions and mutations: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations
Add a label
Embed this post
<iframe src="https://www.hibug.me/react-19-useformstatus-pending-always-false" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I am a frontend engineer who has worked on dashboards, booking flows and design systems, mostly in React and TypeScript. I care about the parts of the web platform that frameworks hide: forms, cookies, rendering cost and what the browser actually does with our code. The bugs I write about usually passed every review and only appeared with production data: a map with a million points, a name with an accent in a PDF, a cookie that one subdomain never sees. Each post shows a minimal reproduction, why the browser or framework behaves that way, and a fix with a test.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.