DEV Community

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

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

Shubhra Pokhariya on July 28, 2026

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...
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
 
ofri-peretz profile image
Ofri Peretz

The queuing behavior is the part that's hardest to explain in a code review β€” the button looks disabled, the test passes on fast CI, and nobody notices until a user on spotty wifi submits a payment twice and you're debugging duplicate orders. I've seen teams reach for debounce as the fix, which just narrows the race window instead of closing it. The actual problem, as you're pointing out, is that pending state needs to be owned by whoever owns the async lifecycle β€” and that's React, not the component. One thing worth flagging for teams adopting this now: LLMs trained on pre-19 codebases will keep generating the three-state isSubmitting pattern indefinitely, so a lint rule or a review note treating manual pending booleans as a smell pays for itself fast.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks! β€œPending state needs to be owned by whoever owns the async lifecycle” is a really clean way to put it. That's the distinction I was getting at with the local flag vs. isPending.

The local flag wasn't wrong because of bad code, it was wrong because it belonged to something that didn't actually control when the request resolved.

The debounce point is a good catch too. I hadn't thought about it that specifically, but you're right that debounce only narrows the window, it doesn't guarantee that duplicate submissions can't happen.

And the LLM angle makes sense given how much pre-19 code is still out there. A lint rule flagging manual isSubmitting/isPending booleans around form submission logic could be a useful way to catch a pattern that's otherwise easy to keep reproducing.

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

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
 
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
 
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
 
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
 
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
 
publiflow profile image
PubliFlow

Good practical examples. One thing worth adding is how error boundaries interact with this pattern β€” unhandled promise rejections in particular can be tricky to catch at the right level.

Collapse
 
frank_signorini profile image
Frank

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

Collapse
 
mia_keller_ffd2584c046ecb profile image
Mia Keller

Great breakdown! The fact that React sequentially queues form actions instead of racing them was a huge lightbulb moment.

In practice, how do you usually handle user feedback when a user does manage to tap submit multiple times on a slow networkβ€”do you just rely on isPending to disable the button, or show a subtle visual indicator that extra clicks are queued/prevented?

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thank you, Mia! 😊 Great question.

In the example I shared, I keep it simple, just disabled={isPending} plus swapping the button text (like "Subscribing..."), so the button clearly communicates that something's happening.

I haven't layered on a separate "queued" indicator in that example, but for something higher-stakes like payments or orders, I'd probably add more visual feedback so users always know what's happening, while still relying on the backend to enforce idempotency where duplicates aren't acceptable.