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...
For further actions, you may consider blocking this person and/or reporting abuse
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.
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
isPendinghas 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.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
isSubmittingpattern indefinitely, so a lint rule or a review note treating manual pending booleans as a smell pays for itself fast.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.
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.
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.
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!
Ah! π€― The behavior of
useActionStatelooks pretty complicated. Nice debugging as always!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.
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.
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.
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?
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
keyapproach when I genuinely want a completely fresh instance of the form.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?
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.
Thanks! π That's a good point. I see these as solving different problems though.
useActionStateis 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.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?
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 withuseActionStatewas 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.
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.
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?
Good addition, remount wiping
useOptimisticstate 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
useActionStatedoesn't provide a built-in cancellation mechanism.Shubhra, the comparison between a local pending state and
useActionStatemade 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 π
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!
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?
That's a really good question. This is actually where
useActionStateanduseOptimisticsplit. On its own,useActionStatewaits for the action to resolve; it doesn't do optimistic updates. For instant UI feedback, I'd pair it withuseOptimistic.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.
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.
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
useOptimisticstate layered on top. The reset-signal approach takes a bit more setup, but I think it composes better as forms get more complex.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.
This is a great breakdown! I'm curious if
useActionStatealso helps abstract awayGreat 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?
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.