{"id":342,"date":"2026-06-19T05:02:20","date_gmt":"2026-06-19T05:02:20","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/useoptimistic-react-optimistic-ui-i-stopped-hand-rolling\/"},"modified":"2026-06-19T05:02:20","modified_gmt":"2026-06-19T05:02:20","slug":"useoptimistic-react-optimistic-ui-i-stopped-hand-rolling","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/useoptimistic-react-optimistic-ui-i-stopped-hand-rolling\/","title":{"rendered":"useOptimistic: The Optimistic UI I Stopped Hand-Rolling"},"content":{"rendered":"<p>A few months ago I shipped a &ldquo;like&rdquo; button that felt broken. You&rsquo;d tap it, nothing would happen for half a second, then the heart would fill in. Functionally correct. It felt awful. Every tester said the same thing: &ldquo;is it working?&rdquo; The button was waiting for the server round-trip before it showed anything, and half a second of nothing reads as a bug to a human thumb.<\/p>\n<p>The fix is optimistic UI: show the result immediately, assume the server will agree, and roll back if it doesn&rsquo;t. I&rsquo;d built this by hand before and it was always fiddly. React 19 shipped a hook called <code>useOptimistic<\/code> that handles the annoying parts, and it&rsquo;s one of the few new APIs I adopted the same week I read about it. Here&rsquo;s how I used to do it, how I do it now, and the places it bites you.<\/p>\n<h2 id=\"the-hand-rolled-version-and-why-it-got-messy\">The hand-rolled version, and why it got messy<\/h2>\n<p>Before the hook, optimistic updates meant keeping a shadow copy of state and reconciling it yourself. For that like button, my old code looked roughly like this:<\/p>\n<pre><code class=\"language-jsx\">function LikeButton({ postId, initialLikes }) {\n  const [likes, setLikes] = useState(initialLikes);\n  const [pending, setPending] = useState(false);\n\n  async function handleLike() {\n    setPending(true);\n    setLikes(likes + 1); \/\/ optimistic bump\n    try {\n      const real = await likePost(postId);\n      setLikes(real);     \/\/ trust the server\n    } catch {\n      setLikes(likes);    \/\/ roll back\n    } finally {\n      setPending(false);\n    }\n  }\n  \/\/ ...\n}\n<\/code><\/pre>\n<p>It works, mostly. But I&rsquo;m tracking three things by hand: the displayed count, the pending flag, and the rollback value. The bugs always came from that rollback line. If a second click landed before the first resolved, my <code>likes<\/code> closure was stale and the rollback restored the wrong number. I shipped that bug more than once, and it&rsquo;s the kind of bug that never shows up in a calm demo and always shows up when a real user gets impatient and double-taps.<\/p>\n<h2 id=\"what-useoptimistic-actually-does\">What useOptimistic actually does<\/h2>\n<p>The hook gives you a temporary state that automatically reverts when the surrounding async action finishes. The <a href=\"https:\/\/react.dev\/reference\/react\/useOptimistic\" rel=\"nofollow noopener\" target=\"_blank\">React docs for useOptimistic<\/a> describe the signature as <code>const [optimistic, addOptimistic] = useOptimistic(actualState, updateFn)<\/code>. You render <code>optimistic<\/code>, you call <code>addOptimistic<\/code> when you kick off the action, and React snaps back to <code>actualState<\/code> once the action settles. You don&rsquo;t write the rollback. That&rsquo;s the whole pitch, and it&rsquo;s the part I kept getting wrong by hand.<\/p>\n<p>Same button, rewritten:<\/p>\n<pre><code class=\"language-jsx\">function LikeButton({ postId, likes, likeAction }) {\n  const [optimisticLikes, addOptimistic] = useOptimistic(\n    likes,\n    (current, amount) =&gt; current + amount\n  );\n\n  return (\n    &lt;form action={async () =&gt; {\n      addOptimistic(1);\n      await likeAction(postId);\n    }}&gt;\n      &lt;button type=&quot;submit&quot;&gt;&amp;#9829; {optimisticLikes}&lt;\/button&gt;\n    &lt;\/form&gt;\n  );\n}\n<\/code><\/pre>\n<p>No pending flag I manage, no rollback value I stash, no stale closure restoring a wrong count. When the action resolves, the parent&rsquo;s <code>likes<\/code> updates and the optimistic value falls away on its own.<\/p>\n<h2 id=\"it-only-works-inside-an-action\">It only works inside an action<\/h2>\n<p>This is the part that tripped me up, and the docs are a little quiet about it. <code>useOptimistic<\/code> reverts when a transition or action completes. If you call <code>addOptimistic<\/code> outside of one, the optimistic value shows up and then never goes away, because there&rsquo;s no action boundary to tell React when to revert.<\/p>\n<p>In practice that means you&rsquo;re using it with form actions or <code>useTransition<\/code>. The cleanest pairing is with <code>useActionState<\/code>, which React 19 introduced alongside it and documents <a href=\"https:\/\/react.dev\/reference\/react\/useActionState\" rel=\"nofollow noopener\" target=\"_blank\">in the same reference set<\/a>. I wrote about how those form APIs changed my approach in <a href=\"https:\/\/abrarqasim.com\/blog\/react-19-changed-how-i-write-forms\" rel=\"noopener\">my post on React 19 forms<\/a>, and <code>useOptimistic<\/code> slots right into that pattern. If you&rsquo;re firing updates from a plain <code>onClick<\/code> with no transition, wrap the work in <code>startTransition<\/code> first, or the revert won&rsquo;t happen.<\/p>\n<p>I burned an hour on this exact thing. I had an optimistic counter wired to a button&rsquo;s <code>onClick<\/code>, no transition anywhere, and the number kept climbing and never resetting. It looked like a state bug deep in my code. It was just me not giving React an action to anchor the revert to. The moment I moved the call into a form action, it behaved.<\/p>\n<h2 id=\"a-more-honest-example-a-message-list\">A more honest example: a message list<\/h2>\n<p>The like button is the toy version. The case where this hook genuinely earned its place was a chat list, where I want a sent message to appear instantly while it&rsquo;s still in flight:<\/p>\n<pre><code class=\"language-jsx\">function Thread({ messages, sendAction }) {\n  const [optimisticMessages, addMessage] = useOptimistic(\n    messages,\n    (current, text) =&gt; [...current, { text, sending: true }]\n  );\n\n  async function formAction(formData) {\n    const text = formData.get(&quot;text&quot;);\n    addMessage(text);\n    await sendAction(text);\n  }\n\n  return (\n    &lt;&gt;\n      {optimisticMessages.map((m, i) =&gt; (\n        &lt;div key={i} style={{ opacity: m.sending ? 0.5 : 1 }}&gt;\n          {m.text}\n        &lt;\/div&gt;\n      ))}\n      &lt;form action={formAction}&gt;\n        &lt;input name=&quot;text&quot; \/&gt;\n      &lt;\/form&gt;\n    &lt;\/&gt;\n  );\n}\n<\/code><\/pre>\n<p>The new message shows up greyed out the instant you hit send, then solidifies when the server confirms. If the send throws, the optimistic entry vanishes on its own because the action ended. This is the behavior every chat app has, and I used to write it with a pile of state and a manual cleanup. Now it&rsquo;s one reducer function.<\/p>\n<h2 id=\"the-reducer-is-the-part-worth-slowing-down-on\">The reducer is the part worth slowing down on<\/h2>\n<p>The second argument to <code>useOptimistic<\/code> is a reducer, and I glossed over it the first time. It takes the current state and whatever you pass to <code>addOptimistic<\/code>, then returns the next optimistic state. The detail I missed: React can run it more than once. If a user fires three quick actions, React replays the reducer over the real base state for each pending update, in order, so the optimistic value reflects all of them at once. That&rsquo;s exactly the case my hand-rolled version got wrong with its stale closure.<\/p>\n<p>Two rules follow from that. Keep the reducer pure, with no fetch calls or side effects inside it, because React may run it repeatedly. And make it work off the current value it&rsquo;s handed, not a variable captured from the surrounding scope. Once I understood that the reducer is replayed rather than called once, the whole hook stopped surprising me. It behaves like every other reducer in React, which turns out to be the entire point.<\/p>\n<h2 id=\"the-places-it-still-bites\">The places it still bites<\/h2>\n<p>I don&rsquo;t want to oversell it. The release that introduced this hook, <a href=\"https:\/\/react.dev\/blog\/2024\/12\/05\/react-19\" rel=\"nofollow noopener\" target=\"_blank\">React 19<\/a>, bundles it with a lot of other action machinery, and you kind of have to buy into that model for it to feel natural. If your app isn&rsquo;t using actions or transitions yet, <code>useOptimistic<\/code> on its own will feel like it&rsquo;s fighting you.<\/p>\n<p>It&rsquo;s also genuinely optimistic, which means it assumes success. If your failure rate is high, flashing a result and yanking it back is worse than a short spinner. I keep optimistic UI for actions that almost always succeed, like a like or a sent message, and I leave slow, failure-prone operations alone with an honest loading state. The other thing I watch is the revert itself: if a message can disappear after a failed send, tell the user it failed, because a silent vanish is its own kind of broken.<\/p>\n<h2 id=\"try-it-on-your-worst-feeling-button\">Try it on your worst-feeling button<\/h2>\n<p>Find the one interaction in your app that feels laggy even though the code is correct. The favorite toggle, the upvote, the little thing users tap and then tap again because nothing happened. Wrap it in a form action and a <code>useOptimistic<\/code> reducer, and watch it go from &ldquo;is this working&rdquo; to instant. That single change does more for perceived speed than most of the performance work I&rsquo;ve done. If you want more of how I think about frontend feel, it&rsquo;s scattered across <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">my portfolio<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>React 19&#8217;s useOptimistic hook replaced my hand-rolled optimistic UI: instant likes and messages with automatic rollback. Here&#8217;s how I use it, with code.<\/p>\n","protected":false},"author":2,"featured_media":341,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"React 19's useOptimistic hook replaced my hand-rolled optimistic UI: instant likes and messages with automatic rollback. Here's how I use it, with code.","rank_math_focus_keyword":"useoptimistic","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[354,35],"tags":[38,69,382,41,359,68],"class_list":["post-342","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-react","category-web-development","tag-frontend","tag-hooks","tag-optimistic-ui-2","tag-react","tag-react-19-2","tag-useoptimistic"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/342","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=342"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/342\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/341"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=342"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=342"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=342"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}