Part 1 was about waiting well. This one is about not waiting at all, and about a couple of mistakes I made along the way that are worth walking thr...
For further actions, you may consider blocking this person and/or reporting abuse
Good debugging post as usual! 😀 Small differences in how functions behave can be surprisingly hard to learn. Following your last post about
useActionState, this time I got to learn aboutuseOptimistic. Thank you!Thanks so much! 😊 It really means a lot that you've been following the series.
Those small behavioral differences are exactly the kind of things I wanted to focus on because they're easy to miss until they show up in a real app. I'm glad you found it useful!
The three mistakes have one root worth naming: optimistic state is derived, not owned. Forgetting
onConfirmedis failing to update the source it derives from, the manual reset is writing to a projection, and generating an id inside the updater assumes it runs once like an event handler rather than as a derivation React is free to re-run. If you catch yourself resetting it, writing to it, or minting values in it, you have started treating a view as a store.One practical follow-on to the temp id: have the server accept the client-generated uuid instead of assigning its own. Then the optimistic row and the confirmed row share a key, the swap reconciles invisibly instead of flashing a duplicate, and you get idempotency on retry for free, since a resubmitted comment with the same id is a no-op rather than a second comment.
Thanks, Vinicius! I really like the way you framed it as "derived, not owned." That ties the three mistakes together really well.
The client-generated UUID suggestion is a great extension too. Letting the server accept it keeps the identity consistent all the way through, and the idempotency benefit on retries is a nice bonus.
Happy it was useful. One edge on the client-generated id: the server has to make the insert idempotent too, not just accept the id.
INSERT ... ON CONFLICT (id) DO NOTHINGplus a read-back, so a retry returns the original row instead of a unique violation the user reads as a real error. And keep identity the only thing the client owns. Timestamps and author stay server-assigned.Also worth generating it as UUIDv7 rather than v4. v7 is time-ordered, so the optimistic row sorts into its final position right away. With v4 you fall back to sorting by timestamp, and the client and server clocks disagree just enough that the row can jump when the confirmed version lands.
crypto.randomUUID()gives you v4, so v7 needs a small helper.Exactly. That completes the picture nicely. Keeping the client-generated ID stable solves the identity side, but the backend still has to make retries idempotent for the whole flow to hold together.
I also hadn't considered the UUIDv7 ordering angle. That's a really elegant way to keep the optimistic and confirmed states aligned.
"Derived, not owned" still feels like the thread that ties all of this together. Thanks for expanding on it, Vinicius!
Glad it landed. Good post to think out loud in.
This is an excellent point by Vinicius. Treating optimistic state as a derived projection rather than a source of truth is a key mindset shift. Another challenge is managing race conditions when multiple actions are in flight - e.g., if you submit comment A, then comment B before A completes.
useOptimistichandles this internally by queueing updates, but if the server response for A fails and B succeeds, reconciling that state back to the 'true' state fromuseActionStaterequires careful state machine design. Client-generated UUIDs definitely simplify the reconciliation here.Thanks, that's a good edge case to raise. One small distinction though: the queueing here is actually coming from
useActionState'sdispatchAction, notuseOptimistic. I covered that in the AbortController section: dispatches are processed in the order they arrive rather than racing each other.In this particular
CommentBox, the A-then-B scenario also can't happen through the UI because the input and submit button are disabled whileisPendingis true. And since both submissions would go through the sameformAction,useActionStatewould process them sequentially anyway.So there's no A-fails/B-succeeds reconciliation race in this specific example. The broader concern is definitely relevant for optimistic updates that involve multiple independent actions, where nothing is serializing the requests for you. That's where the reconciliation and state-machine complexity becomes much more interesting.
And yes, the client-generated ID still helps with identity and retry reconciliation, as Vinicius pointed out earlier.
Great breakdown. At IT Path Solutions, we've seen that optimistic UI patterns deliver the best experience when they're paired with careful state transition testing. Most production issues tend to appear where multiple hooks and async workflows intersect, making end-to-end validation just as important as individual feature testing.
Thanks, Glen! I agree. Neither hook was the problem on its own. It was the interaction between them that exposed the edge case, and that was the part I found most interesting to debug.
"optimistic state is derived, not owned" is the mental model I wish I'd had earlier. the trap we hit was treating the optimistic setter like a manual state toggle and expecting it to survive form resets. it doesn't — once the transition settles, the value prop overwrites it, which is exactly correct behavior but feels like a bug when you're not expecting it.
the manual rollback pattern is gnarly at scale too. once you have more than two in flight optimistic updates at once, the rollback order starts mattering and things get messy fast.
how are you handling the case where useActionState's reset and useOptimistic's revert disagree on timing?
Exactly, Mudassir. That's how I think about it now. In this particular example, I avoid the timing conflict rather than trying to coordinate two resets.
Resetis disabled whileisPendingis true, so by the time it can actually run, the Transition has already settled andoptimisticCommentsis already back tocomments. There's nothing left foruseOptimisticto reset at that point.formAction(null)only clears theuseActionStateresult, which is the one piece of state that was actually still holding onto something.If the two ever did need to reset at genuinely different moments, I'd rather model that as two explicit signals than add a second reset path into
useOptimisticand end up fighting a lifecycle it's already managing correctly on its own.And yeah, agreed on the manual rollback problem. Past a couple of updates in flight, you're basically hand-building the reconciliation
useOptimisticgives you for free. That's really where "derived, not owned" earns its keep, once you stop treating the optimistic setter like a secondsetStateand let it just be a projection ofcomments.Shubhra, I don't know React deeply enough yet to say much on the technical side 😄 but I've been enjoying how you walk through the mistakes you ran into instead of just showing the final working version. It makes the debugging process interesting to follow 😀
Thank you so much, Hemapriya! 😊 That really means a lot.
I'm glad it was still easy to follow even without a deep React background. That was one of my goals while writing it. I also enjoy walking through the mistakes because they're often where the real learning happens. Thanks for reading and for sharing that!
Super interesting read! I'm curious if you considered using a
keyprop on the form itselfThanks, Frank! I'm really glad you enjoyed the post. 😊
Good question. I did consider it, but a
keyon the<form>itself wouldn't affectuseActionStateoruseOptimisticsince those hooks live onCommentBox, not the form. The remount trade-off I described only comes into play ifCommentBoxitself is remounted.man useOptimistic is such a lifesaver, but yeah the state reset logic with useActionState is always a headache lol
That was my first impression too. In the end, I realized I only needed to reset one piece of state.
did the same thing with useOptimistic last week and spent two hours debugging the state reset lol. definitely a weird quirk with how it interacts with actions.
Yeah, that debugging session is basically what pushed me to write this up. It feels like a quirk at first, but it's really just state living on the component that owns the hook. Once I traced where the state lived, the behavior stopped being surprising.
Awesome post! 🔥 Loved how you explained everything so simply. Super helpful! 🙌
Thank you so much Elsie! 😊 I'm really glad you enjoyed it. I always try to keep things as simple and practical as possible. Thanks for reading!
The section on AbortController and pending actions provides valuable context for handling edge cases.
Thanks! I'm glad that section was useful. Cancelling on the client doesn't undo a mutation that's already been processed by the server, so it's not a pattern that's safe for every action.
Really interesting write-up! I've actually just recently started hearing about these React 19 hooks, so this was super insightful. Thanks for sharing!
Thanks, Hosein! 😊 I'm glad you found it useful. Thanks for reading!
Really good breakdown. The reset behavior with useOptimistic and useActionState is easy to get wrong. Thanks for sharing the real debugging experience.
Thanks so much! 😊 That was definitely one of the more interesting parts to debug. Once I understood what
useOptimisticwas actually doing there, the reset behavior stopped feeling like a quirk.