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

Fixes focus loss for keyboard users

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 (21)

Collapse
 
elijahbrown profile image
Elijah Brown •

The aria-disabled pending pattern is a good catch. One related trap on contact forms: a well-formed email on a domain with no MX still looks like a successful submit until the confirmation bounces.

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thanks Elijah, good trap to call out. type="email" only checks the shape, not whether the domain can actually receive mail.

I'd do the DNS check server side inside the action and return an error state from there. One caveat is that a missing MX isn't always a dead end, since mail can fall back to an A record, so I'd warn the user rather than hard-block on that alone.

Collapse
 
elijahbrown profile image
Elijah Brown •

If you end up wanting that as one call rather than wiring the DNS lookups into the action yourself, I built an API for the domain side: MX, Null MX and a list of about 9,000 known throwaway providers. Happy to say more if it helps.

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya •

Thanks for the offer, Elijah. For now, I'm keeping the check inside the action, but I'll keep it in mind.

Thread Thread
 
elijahbrown profile image
Elijah Brown •

Keeping it inside the action makes sense when the check is part of the same decision boundary. The important bit is that the result stays explicit instead of silently changing the submit path.

Collapse
 
elijahbrown profile image
Elijah Brown •

Agreed on warning rather than blocking there, since a domain with no MX falls back to its A record for mail. The one case I'd block outright is a Null MX, because that domain has published that it takes no mail at all.

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya •

Makes sense, Elijah. A Null MX is the domain saying outright that it takes no mail, so I'd block on that. A plain missing MX I'd just warn on.

Thread Thread
 
elijahbrown profile image
Elijah Brown •

Agreed. A missing MX should be treated as inconclusive because of A-record fallback, while an explicit Null MX is a clear reason to stop.

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya •

That distinction makes sense. Warn on a missing MX, block on a Null MX.

Thread Thread
 
elijahbrown profile image
Elijah Brown •

That is the split I'd use too. It keeps the warning path honest without turning an incomplete DNS answer into a false rejection.

Collapse
 
usernameinvalid profile image
Larry Z. •

This is the kind of post that actually moves the needle for people shipping real forms.

You didn’t just restate the useFormStatus placement gotcha — you productized the fix and then kept going until the second-order failures showed up. The switch from disabled to aria-disabled + manual click guard is the part that quietly matters most. Focus loss on submit is one of those a11y landmines that only becomes obvious once a keyboard user hits Enter and the focus vanishes into the void. Using aria-disabled and aria-busy while still blocking the action is the correct pattern, and most people still get this wrong.

The intent-aware status hook is equally sharp. Pending is form-scoped, not button-scoped, so the naïve approach lights up every submit button. Reading the submitted intent back out of status.data is the cleanest way I’ve seen to scope the pending state without inventing extra React context. Bonus points for calling out the HTML default-button behavior when someone presses Enter — that’s the kind of detail that saves a future “why does Save Draft keep winning?” debugging session.

PendingFieldset is a nice pragmatic choice. Native <fieldset disabled> is still under-used, and the CSP-aware unstyled escape hatch shows you’ve actually shipped this under real constraints. Same for the live-region status text — aria-busy alone is never enough, and you handled it correctly.

The second-pass placement checks on the fieldset and status components are the real sign of quality. Silent failure is the original bug’s signature; making every consumer of useFormStatus fail loudly in development is the right hardening. The unsafeSkipFormCheck escape hatch for portals is also well-judged — the React tree vs DOM mismatch is exactly the edge case that bites people who are otherwise doing everything right.

Known limits section is honest and useful (per-button formAction, requestSubmit, double-click race). That’s the difference between a clever snippet and something someone can actually drop into a production codebase with eyes open.

Single-file, zero-deps, accessibility-first solution that fixes both the original silent failure and the follow-on UX/a11y issues. This is the version people should be copying.

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thanks Larry 🙂 I really appreciate you taking the time to go through this in such detail. I went with aria-disabled because I didn't want the pending fix to introduce a new focus problem for keyboard users.

For the intent handling, the hook reads the submitted button's name and value from status.data, which lets me narrow the form-level pending state down to the action that was actually submitted.

Collapse
 
webdeveloperhyper profile image
Web Developer Hyper •

Yes, it's hard to notice a silent bug. I wish all bugs were noticeable and easy to find. Nice bug smash! 😄

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thanks! 😊 The silent ones usually take the longest to notice. No error, no console message, the button just never changes. That's why I added a dev warning for it.

Collapse
 
mudassirworks profile image
Mudassir Khan •

the mental model shift that got us is that useFormStatus reads from the nearest form ancestor in the component tree, not the closest DOM form element. we had a modal with a native form element where status was always false because our SubmitButton was outside the actual React Form component boundary. wrapping with your SubmitButton pattern fixed it immediately. the subtle part is that it also needs to be in a component that renders inside the action bound form, not just a sibling. did you run into cases where the pending state was delayed even with the correct nesting?

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thanks Mudassir, glad it fixed the modal! 😊 The nesting point is right. The hook has to be called in a component that renders inside the form, so a button rendered outside the form's React tree won't see the form status.

One thing worth checking in that modal: if the form submits through a plain onSubmit that only calls preventDefault(), pending stays false even with correct nesting, because there's no Action for the hook to track. The form needs a function passed to its action.

On the delay, not in my testing so far. I've tested Chromium and Firefox, and the timing edge case I know about is two synchronous clicks in the same task getting through before React re-renders and pending flips.

Collapse
 
kyisaiah47 profile image
kyisaiah47 •

How does the placement check identify an intentional portal without letting unsafeSkipFormCheck hide a component that was accidentally rendered outside the form?

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

It doesn't try to infer whether the portal is intentional. The check only looks at the DOM and warns when it can't find a parent form, while useFormStatus follows the React tree, so a portal and a misplaced component can look the same to it. unsafeSkipFormCheck is an explicit opt-out for when you know the component is meant to sit outside the form.

So yes, setting it can hide an accidental placement. That's why I made it explicit and gave it the unsafe name, rather than making the check silently skippable.

Collapse
 
hemapriya_kanagala profile image
Hemapriya Kanagala •

Shubhra, the keyboard part was really useful 😀 I wouldn’t have thought about focus getting lost when using disabled. And the two buttons showing pending for the same form was another good catch!

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thanks Hema! 😊 The focus behavior is easy to overlook, especially when disabled seems like the obvious way to prevent another submit. And the two buttons showing the same pending state makes more sense once you remember that pending belongs to the form, not the button you clicked.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.