DEV Community

Cover image for React 19 useFormStatus Returning False? I Built a SubmitButton That Fixes It
Shubhra Pokhariya
Shubhra Pokhariya

Posted on AI-assisted

React 19 useFormStatus Returning False? I Built a SubmitButton That Fixes It

A few weeks back I wrote about the useFormStatus bug that got me the hardest, the one where pending sits at false forever because the hook was called in the wrong component. If you haven't read that one, the short version is this: the hook only reports on a parent form, never one rendered by the same component calling it, and nothing warns you when you get it wrong. No error, no console message, the button just quietly never enters a pending state.

That post explained the bug. This one is about what I actually built once I got tired of re-explaining the same fix to myself on every new project, and about a second version of that exact same silent failure I didn't notice until I'd already shipped the first fix.

One scope note before the code: this fixes the placement mistake. If pending is false because the form submits through a plain onSubmit handler that only calls preventDefault(), or through a URL string action, moving the hook won't change anything, because there is no Action for it to track.

What the first post didn't cover

Moving the hook fixes the placement bug, but I ran into two more problems as soon as I used it in real forms.

The first is disabled={pending} on the button itself. It works, and it's also the wrong choice if you care about keyboard users. A disabled element drops focus the instant it becomes disabled. Someone tabs to your submit button, hits Enter, and focus jumps away mid submission, sometimes landing nowhere useful at all. The fix is aria-disabled instead of disabled, with the click blocked manually so repeat submissions still get ignored.

function SubmitButton({ children, pendingText, intent }) {
  const { pending } = useFormStatus();

  function handleClick(e) {
    if (pending) e.preventDefault();
  }

  return (
    <button
      type="submit"
      name={intent ? "intent" : undefined}
      value={intent}
      aria-disabled={pending || undefined}
      aria-busy={pending || undefined}
      data-pending={pending ? "" : undefined}
      onClick={handleClick}
    >
      {pending ? pendingText : children}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Focus stays exactly where it was. Style the pending state off aria-disabled or data-pending, not :disabled, since the native attribute is never actually set here.

The second problem shows up once a form has two submit buttons. pending belongs to the whole form, not to whichever button was clicked, so a Save Draft and a Publish button next to each other both light up for one click. The browser already knows which button submitted. Its name and value are in the FormData, so you read them back out and compare.

function useFormStatusForIntent(intent) {
  const status = useFormStatus();
  const submittedIntent = status.data?.get("intent");
  const isThisIntentPending =
    status.pending &&
    (intent === undefined || submittedIntent == null || submittedIntent === intent);
  return { ...status, isThisIntentPending };
}
Enter fullscreen mode Exit fullscreen mode
<form action={savePost}>
  <textarea name="body" />
  <SubmitButton intent="draft" pendingText="Saving draft...">Save Draft</SubmitButton>
  <SubmitButton intent="publish" pendingText="Publishing...">Publish</SubmitButton>
</form>
Enter fullscreen mode Exit fullscreen mode

The button shown earlier is simplified to keep the focus on aria-disabled. In the full file, SubmitButton calls this hook, so only the button someone actually pressed shows pending. One gotcha before you wire this up: pressing Enter in a text field submits through whichever button comes first in the markup. That's the HTML spec's definition of the default button, not something React decides. Put your safest action first.

Locking the rest of the form while it submits

Next you'll probably want the rest of the form locked while the request is in flight, so nobody edits a field mid submit. A native <fieldset> does this in one line instead of one disabled prop per input.

function PendingFieldset({ children, unstyled }) {
  const { pending } = useFormStatus();
  return (
    <fieldset
      disabled={pending}
      style={unstyled ? undefined : { border: 0, margin: 0, padding: 0 }}
    >
      {children}
    </fieldset>
  );
}
Enter fullscreen mode Exit fullscreen mode

The unstyled flag is for CSP. The inline reset removes the fieldset's default border, but a CSP that blocks inline styles ignores it on server-rendered HTML and the border comes back. If that's you, pass unstyled and style it with a class.

One trade-off: a focused input loses focus when the fieldset disables it, the same thing that happens to a disabled button. Use it where blocking edits matters more than keeping focus.

For a screen reader announcement, aria-busy alone isn't read out loud. You need an actual live region for that.

function FormStatusText({ pendingText, idleText = null }) {
  const { pending } = useFormStatus();
  return (
    <span role="status" aria-live="polite" aria-atomic="true">
      {pending ? pendingText : idleText}
    </span>
  );
}
Enter fullscreen mode Exit fullscreen mode

The bug I didn't catch until the second pass

Here's the part that made me go back and touch the file again after I thought it was already done.

SubmitButton had a dev-only check from the start. Render it outside a form and you get a console error in development explaining why it won't work.

PendingFieldset and FormStatusText had the identical requirement, "must be rendered inside the form", written right there in their own comments, but neither one actually had the check. Misplace either one and it fails as silently as the original bug: the fieldset never disables, the status message never appears, and nothing warns you.

It's an easy mistake to make, since these two feel less important than the submit button and tend to get dropped in wherever is convenient.

Both now run the same placement check the button already had, with the same escape hatch for anyone using a portal on purpose:

<PendingFieldset unsafeSkipFormCheck>
  {/* rendered through a portal: outside the form in the DOM, inside it in the React tree */}
</PendingFieldset>
Enter fullscreen mode Exit fullscreen mode

The check has a limit. It only looks at the DOM, but useFormStatus follows the React tree. A component rendered through a portal can track the form correctly and still trigger the warning, because in the DOM it isn't inside the form. That's what unsafeSkipFormCheck is for.

Where this landed

The full file has the button, the fieldset, the live region, the intent hook, and the placement check on all three components. One file, no dependencies beyond react and react-dom.

Known limits: intent doesn't work with a per-button formAction, form.requestSubmit() skips the click guard, and two synchronous clicks in the same task both get through before React re-renders. I've only tested in Chromium and Firefox so far. Safari and screen readers are still untested.

If you want the whole thing: useFormStatus SubmitButton. Free, single file.

If you're catching this one first and want the full explanation of why the original bug happens, that's the earlier post.

Top comments (0)