Web Development Bug Report

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.

Hannah Okafor Hannah Okafor @hannahokafor · August 03, 2026 · 7 min read · 34 Views
Banner: React 19 useFormStatus pending is always false because the hook must live inside 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. react and react-dom were 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 onSubmit with local state for pending. Works, but loses progressive enhancement and the integration with transitions that form actions provide.
  • useTransition around a custom submit handler, which gives isPending for 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 useFormStatus is 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 useFormStatus only in components rendered inside the form.
  • Use useActionState when 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

Add a label

#react #hooks #forms #react-19 #server-actions
Written by
Hannah Okafor @hannahokafor

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.