DEV Community

Cover image for React 19's useActionState Showed Me Why Disabling My Submit Button Was Never Enough
Shubhra Pokhariya
Shubhra Pokhariya

Posted on • Edited on • Originally published at shubhra.dev

React 19's useActionState Showed Me Why Disabling My Submit Button Was Never Enough

Comments debate the limits of UI locks

Every form I ever shipped before React 19 needed the same three pieces of state, and I wired them up by hand every single time.

One for the result. One for whether it's submitting. One for the error. And at least once a sprint, I'd forget to reset one of them in the right place and spend twenty minutes staring at a button that wouldn't re-enable.

Here's the version I wrote for years, probably the same one you have sitting in a dozen components right now:

function OldSignupForm() {
  const [error, setError] = useState(null);
  const [isSubmitting, setIsSubmitting] = useState(false);

  async function handleSubmit(e) {
    e.preventDefault();
    setIsSubmitting(true);
    setError(null);
    try {
      await signup(new FormData(e.target));
    } catch (err) {
      setError(err.message);
    } finally {
      setIsSubmitting(false);
    }
  }

  return <form onSubmit={handleSubmit}>{/* ... */}</form>;
}
Enter fullscreen mode Exit fullscreen mode

Three state variables. A try/catch/finally. And one bug waiting to happen the moment someone forgets that finally.

I didn't think this pattern was a problem until I actually sat down with useActionState and realized the workaround I'd been shipping to "fix" double submits was never actually fixing anything.

The habit that felt safe but wasn't

Here's what I mean. Most of us migrate a form like this by adding some version of:

function handleClick() {
  setLocalPending(true); // feels safe, isn't tied to anything real
}
Enter fullscreen mode Exit fullscreen mode

On a fast connection, you'll never notice this is broken. On a slow one, that local flag can flip back to false before the actual request has resolved. A user on spotty wifi taps "Place Order" twice, maybe three times, because the button looked re-enabled for a split second. Each tap still goes through. React doesn't drop them, and it doesn't race them either. It queues them and runs them one after another, in order. So instead of one order, you've got three, each one processed like it was intentional.

That was the moment useActionState stopped feeling like just another hook to memorize. It started feeling like React quietly admitting they had watched enough of us get this wrong and finally built the fix.

What it actually does

const [state, formAction, isPending] = useActionState(fn, initialState);
Enter fullscreen mode Exit fullscreen mode

You give it a function, React calls that function with (previousState, formData) whenever the form submits, and whatever it returns becomes your new state. isPending is tied to React's own transition tracking, not a boolean you're guessing the timing of.

A working example, no framework required, plain React:

import { useActionState } from "react";

async function subscribe(previousState, formData) {
  const email = formData.get("email");

  if (!email || !email.includes("@")) {
    return { success: false, message: "Enter a valid email." };
  }

  await new Promise((resolve) => setTimeout(resolve, 800));
  return { success: true, message: "You're subscribed." };
}

function NewsletterForm() {
  const [state, formAction, isPending] = useActionState(subscribe, {
    success: false,
    message: "",
  });

  return (
    <form action={formAction}>
      <input type="email" name="email" placeholder="you@example.com" />
      <button type="submit" disabled={isPending}>
        {isPending ? "Subscribing..." : "Subscribe"}
      </button>
      {state.message && <p>{state.message}</p>}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

No onSubmit. No preventDefault. No separate pending flag to keep in sync. Swap disabled={isPending} in for that fake local flag, and the double-submit problem you thought you'd solved actually gets solved.

The part that genuinely surprised me

I assumed rapid clicks would race each other. Maybe the last one wins, maybe the first one does, classic race-condition territory. That's not what happens.

React queues every call to the action function and runs them sequentially. Each one waits for the previous one to finish before it starts. Click "Add to Cart" four times fast and it takes roughly four times as long to settle, not because anything's broken, but because call two is patiently waiting for call one's promise to resolve first. Nothing races. Nothing gets silently dropped.

Which means the real problem was never a corrupted database write. The problem is that every click still counts. Five taps on a slow connection means five processed actions, not one. disabled={isPending} isn't there to prevent a race, since there isn't one. It's there to stop someone from queuing up four actions they never meant to trigger in the first place.

One more thing worth knowing before it bites you. If an earlier call in that queue throws, React skips every call still waiting behind it. Catch your errors and return a state object instead of letting anything throw, or you'll lose queued actions you never even knew were sitting there.

async function submitOrder(previousState, formData) {
  try {
    const result = await placeOrder(formData);
    return { success: true, orderId: result.id };
  } catch (err) {
    return { success: false, error: "Something went wrong. Try again." };
  }
}
Enter fullscreen mode Exit fullscreen mode

Two more traps I walked straight into

Stale closures. If your action function reaches into component-scoped variables instead of pulling straight from formData.get(...), you're one re-render away from acting on data that's already out of date. Prefer reading submitted values from formData instead of relying on values captured from an earlier render.

Forgetting there's no reset button. useActionState has no built-in way to clear its own state. The docs say so plainly. If you need a "start over" button, you have two real options. Teach your action function to recognize a reset signal as one of its inputs, or change the component's key prop to force a full remount. The reset-signal route is usually the less disruptive one, since it doesn't tear the DOM down to do it.

When I don't reach for it

If the interaction needs to feel instant, think a like button or a checkbox, anything where the UI should update before the server has even responded, useActionState will always feel a beat too slow for that. That's useOptimistic's job, and it's worth pairing the two once you've got the basics down.

And if it's a multi-step wizard with controlled inputs across steps that aren't all mounted at once, fighting the uncontrolled-form model useActionState is built on usually costs you more code than it saves.

The actual takeaway

The pattern you've probably been shipping, disable on click and hope for the best, was hiding the real issue instead of solving it. Every click still lands whether the button looks disabled or not. useActionState doesn't just cut boilerplate. It ties your pending state to something real, so the thing you thought you fixed actually gets fixed.

I wrote the full breakdown on my site, including the complete production signup example with field-level validation, the side-by-side comparison table against useState and useFormStatus, and the accessibility details around aria-busy and disabling inputs during a pending state: React 19 useActionState Explained

I honestly thought I understood this pattern until I started testing it properly. It turned out the problem wasn't React at all. It was the way I'd been managing pending state for years.

If you've been relying on your own pending state to handle form submissions, it's worth taking another look. I know I was surprised by what actually happens.

Top comments (30)

Collapse
 
nazar-boyko profile image
Nazar Boyko

If the button is disabled while isPending, when does the queue actually fill up? A disabled submit button blocks the Enter key submit too, so I can't picture the path that stacks four actions unless something is calling formAction directly.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

That's a good question Nazar. 😊 Once the button is actually disabled in the DOM, further clicks and Enter submits are blocked. The queuing behavior I was describing is the brief window before isPending has been committed to the UI. If multiple submit events are dispatched during that window, React queues the corresponding actions and processes them sequentially.

My point was that disabled={isPending} ties the pending state to React's actual action lifecycle instead of relying on a local flag whose timing can drift.

Collapse
 
merbayerp profile image
Mustafa ERBAY

The explanation of Action queuing and error handling is useful, but I would separate UI submission control from operation correctness.

disabled={isPending} gives the pending state a reliable React-managed lifecycle and prevents most accidental repeated clicks after the UI commits. It still cannot guarantee exactly-once execution. A second event may arrive before the disabled state is rendered, and retries, multiple tabs, programmatic submissions, or another client can still repeat the same operation.

For orders, payments, invitations, or other non-repeatable mutations, the real boundary must remain on the server: use an idempotency key, an appropriate unique constraint, and an atomic transaction. useActionState improves coordination and user experience; it is not a replacement for backend idempotency.

I would also distinguish resetting the action state from resetting the form itself. useActionState does not expose a state-reset setter, but React 19 can reset uncontrolled form fields after a successful Action, and requestFormReset exists for manual form resets.

Overall, this is a helpful explanation of the Hook. I would just avoid framing disabled={isPending} as fully solving double submission, because the most important protection still belongs behind the API boundary.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Really appreciate this, Mustafa. 😊 My focus here was the client-side UX side of the problem, specifically why isPending is more reliable than a local pending flag for preventing accidental repeat submissions. I completely agree that exactly-once execution is a server-side concern. Idempotency really does belong at the API boundary. Appreciate you adding that bigger-picture perspective.

Collapse
 
merbayerp profile image
Mustafa ERBAY

Thanks, Shubhra. 😊 The client-side distinction you explained is still very useful, especially the difference between a guessed pending flag and React-managed pending state. I just wanted to make sure readers don’t confuse better UX coordination with exactly-once guarantees. Great discussion!

Collapse
 
webdeveloperhyper profile image
Web Developer Hyper

Ah! 🤯 The behavior of useActionState looks pretty complicated. Nice debugging as always!

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thank you! 😊 It definitely surprised me while I was testing it. I went in expecting a race condition, but the actual behavior was completely different.

Collapse
 
publiflow profile image
PubliFlow

Interesting approach here. I've found that combining this with proper state management (whether Zustand, Jotai, or even just careful use of useContext) makes a significant difference in maintainability as the codebase grows.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks! 😊 That's a good point. I see these as solving different problems though. useActionState is scoped to a single form's submission state, while Zustand, Jotai, or Context are about sharing state across the app. Different layers, but I agree state management choices matter a lot as an application grows.

Collapse
 
publiflow profile image
PubliFlow

You nailed the distinction between localized form state and global application state. By keeping submission logic strictly within useActionState, we actually prevent our global stores like Zustand from getting cluttered with transient UI flags. Have you found yourself moving more of these server-action related states out of your global context since adopting React 19?

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya

Thanks! 😊 For me it wasn't really a shift away from global state, these flags were already local useState, never in a global store. What changed with useActionState was that the submission lifecycle became much more reliable.

I still think global state is the right place for application-wide data, but form submission state feels like it belongs with the form itself.

Thread Thread
 
publiflow profile image
PubliFlow

That clarification makes perfect sense, especially since the true advantage of useActionState is how it intrinsically ties the pending state to the actual network request lifecycle. Colocating that submission state with the form eliminates so many edge cases where manual toggles get out of sync with the server. It really reinforces the broader principle of keeping state as close to where it is consumed as possible.

Collapse
 
publiflow profile image
PubliFlow

Solid coverage of React fundamentals. For production apps, I'd also recommend setting up React Error Boundaries at strategic points — they catch rendering errors gracefully and prevent the entire UI from crashing.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks! 😊 Good point, it's a different layer though. What I covered here was handling errors inside the action by returning an error state instead of letting it throw. Error Boundaries handle rendering errors, so the two complement each other in a production app.

Collapse
 
publiflow profile image
PubliFlow

That distinction between action-level state and rendering-level boundaries makes perfect sense. Combining a graceful UI fallback from useActionState with a crash-safe Error Boundary definitely creates a much more resilient user experience. Have you found any specific patterns for resetting that action state once the user recovers from the error?

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya

Thanks! 😊 For the cases I covered here, I'd still lean toward the reset-signal approach. It keeps the form mounted and lets the action handle its own reset, which I find less disruptive than forcing a remount. I'd only reach for the key approach when I genuinely want a completely fresh instance of the form.

Thread Thread
 
publiflow profile image
PubliFlow

I agree that preserving the mounted state is crucial when you just need to clear inputs without losing local UI context like scroll position or focus. The reset-signal approach definitely feels more surgical for standard submissions, whereas the key prop is essentially a nuclear option for when the entire component tree needs a hard refresh. Have you found any edge cases where managing the reset-signal gets too tangled with complex nested form state?

Collapse
 
hemapriya_kanagala profile image
Hemapriya Kanagala

Shubhra, the comparison between a local pending state and useActionState made the difference much easier to understand.

I also didn't realize React queues those actions instead of racing them. That was a really interesting takeaway. Thanks for sharing 😀

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thank you, Hemapriya! 😊 That queueing behavior really surprised me too when I first tested it. It's easy to assume it's a race condition until you actually see it happen. I'm glad the comparison helped it click!

Collapse
 
mia_keller_ffd2584c046ecb profile image
Mia Keller

Great breakdown on handling form states in React 19! Beyond button disabling and UI locks, how do you handle optimistic UI updates with useActionState when network requests take longer than expected or fail silently on mobile browsers?

Collapse
 
shubhradev profile image
Shubhra Pokhariya

That's a really good question. This is actually where useActionState and useOptimistic split. On its own, useActionState waits for the action to resolve; it doesn't do optimistic updates. For instant UI feedback, I'd pair it with useOptimistic.

For failures, especially on flaky mobile connections, I prefer returning a state object instead of letting the action throw, so the UI has something concrete to show instead of hanging.

Collapse
 
vijay_kanna_56 profile image
Vijay Kanna

I'd probably default to the reset-signal-as-input approach too—not just because remounting loses focus or scroll state, but because it also throws away any useOptimistic state layered on top. Once you've combined the two, a remount can cause optimistic UI to disappear instead of simply resetting the form.

The reset-signal approach feels a bit more verbose initially, but it seems to compose better as forms become more complex.

One thing I'm still curious about: how do you handle a reset while an action is still pending? Do you ignore the reset until the action settles, or is there a clean cancellation pattern that I'm missing?

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Good addition, remount wiping useOptimistic state too is a real cost I didn't spell out. I agree the reset-signal approach holds up better as forms get more complex.

As for resetting while an action is still pending, I didn't cover that in the post. I'd probably wait for the action to settle before applying the reset, since useActionState doesn't provide a built-in cancellation mechanism.

Collapse
 
talha_ramzan_3878156fea8c profile image
Talha Ramzan

The distinction between "this looks disabled" and "this is actually tied to something real" is the whole article in one sentence, honestly. I've shipped that exact setLocalPending(true) pattern more times than I'd like to admit, and it never occurred to me that the flag could flip back before the request actually resolved, it just felt safe because the button visually looked right on my fast dev connection.

The queuing behavior surprised me too. I'd have assumed rapid clicks either race or get debounced somehow, not that React just patiently processes all of them in order. That reframes the whole problem , disabled={isPending} was never preventing a race condition, it's preventing the user from queuing up actions they didn't mean to trigger at all.

The "no reset button" gotcha is the one I'd have definitely walked into blind. Curious which of your two workarounds (reset-signal-as-input vs. remount via key) you'd actually reach for by default, remounting feels like the "obviously works" option but I'd guess it fights you the moment the form has any local UI state (focus, scroll position) you don't want to lose.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thank you, Talha! 😊 This is a great summary. And "looked right on my fast dev connection" is exactly the trap. Everything feels fine until you hit slower or less predictable conditions.

To your question, I'd default to the reset-signal approach over remounting. Remounting looks like the easy option, but it wipes focus, scroll position, and any useOptimistic state layered on top. The reset-signal approach takes a bit more setup, but I think it composes better as forms get more complex.

Collapse
 
frank_signorini profile image
Frank

This is a great breakdown! I'm curious if useActionState also helps abstract away

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