All articles
Web DevelopmentNovember 3, 2026 6 min read

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 memo components that call useFormStatus, and don't memo their 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 / revalidateTag to refetch
  • Suspense boundaries to resolve with the new data
  • useOptimistic to 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 pending through 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:

  1. Grep for every useFormStatus() call and confirm it lives in a descendant of a <form>, not in the form component itself.
  2. Add a lint rule or code review checklist item: "no memo on components calling useFormStatus."
  3. Audit submit buttons for disabled vs aria-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.

#React 19#Next.js#Server Actions#Forms#Web Development

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project