{"id":571,"date":"2026-08-11T05:03:51","date_gmt":"2026-08-11T05:03:51","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/useoptimistic-react-19-the-spinner-i-finally-deleted\/"},"modified":"2026-08-11T05:03:51","modified_gmt":"2026-08-11T05:03:51","slug":"useoptimistic-react-19-the-spinner-i-finally-deleted","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/useoptimistic-react-19-the-spinner-i-finally-deleted\/","title":{"rendered":"useOptimistic in React 19: The Spinner I Finally Deleted"},"content":{"rendered":"<p>I spent twenty minutes last month staring at a like button. Not designing it. Watching it. Tap, wait, spinner, count goes up. On hotel wifi the wait was long enough that I tapped again and accidentally unliked the thing. Somewhere around tap three I admitted the problem wasn&rsquo;t the network. It was me, and the wall of pending-state code I&rsquo;d written to manage one boolean.<\/p>\n<p>Short version for the impatient: React 19&rsquo;s useOptimistic hook shows the expected result of an async action immediately, then reconciles with the real state when the action finishes. You get to delete your snapshot code, your rollback code, and most of your spinners. The longer version, including the two places it bit me, is below.<\/p>\n<h2 id=\"what-an-optimistic-update-actually-is\">What an optimistic update actually is<\/h2>\n<p>An optimistic update means rendering the UI as if the server already said yes. The user clicks &ldquo;like&rdquo;, the count goes up right away, and the request happens in the background. If the request fails, you put things back and explain what happened.<\/p>\n<p>Users don&rsquo;t experience this as a trick. They experience it as the app working. Every decent native app does this, and the only reason web apps got away with spinners for so long is that we all quietly agreed 400ms round trips were normal.<\/p>\n<p>The concept is old. The implementation in React has historically been miserable, because you own the rollback. You need a copy of the previous state, error handling that restores it, and a plan for what happens when two updates are in flight at once. I&rsquo;ve written that code maybe thirty times across client projects. I&rsquo;d estimate I got it fully right twice, and one of those was by accident.<\/p>\n<h2 id=\"how-i-did-it-in-react-18\">How I did it in React 18<\/h2>\n<p>Here&rsquo;s an honest version of my old like button. Trimmed, but not a straw man:<\/p>\n<pre><code class=\"language-jsx\">function LikeButton({ post }) {\n  const [likes, setLikes] = useState(post.likes);\n  const [liked, setLiked] = useState(post.likedByMe);\n  const [error, setError] = useState(null);\n\n  async function handleLike() {\n    \/\/ snapshot for rollback\n    const prevLikes = likes;\n    const prevLiked = liked;\n\n    \/\/ optimistic update\n    setLikes(liked ? likes - 1 : likes + 1);\n    setLiked(!liked);\n    setError(null);\n\n    try {\n      await api.toggleLike(post.id);\n    } catch (e) {\n      \/\/ rollback by hand\n      setLikes(prevLikes);\n      setLiked(prevLiked);\n      setError(&quot;Couldn't save that. Try again?&quot;);\n    }\n  }\n\n  return (\n    &lt;button onClick={handleLike}&gt;\n      {liked ? &quot;\u2665&quot; : &quot;\u2661&quot;} {likes}\n    &lt;\/button&gt;\n  );\n}\n<\/code><\/pre>\n<p>This works until someone clicks twice quickly. The second click&rsquo;s snapshot captures the first click&rsquo;s optimistic value, so if the second request fails, the &ldquo;rollback&rdquo; restores a number that was never real. Fixing that properly means request queues, or ignoring stale responses, or disabling the button while a request is pending, which defeats the entire point. By the time I&rsquo;d handled the edge cases on one project, the like button had more logic than the page it lived on.<\/p>\n<h2 id=\"the-react-19-version\">The React 19 version<\/h2>\n<p>useOptimistic shipped stable in React 19. The <a href=\"https:\/\/react.dev\/blog\/2024\/12\/05\/react-19\" rel=\"nofollow noopener\" target=\"_blank\">React 19 release post<\/a> introduces it alongside Actions and useActionState, and the three are clearly designed as a set. The hook takes your real state plus an update function, and hands back a temporary copy you can change instantly:<\/p>\n<pre><code class=\"language-jsx\">function LikeButton({ post, onToggle }) {\n  const [optimistic, addOptimistic] = useOptimistic(\n    { likes: post.likes, liked: post.likedByMe },\n    (current) =&gt; ({\n      likes: current.liked ? current.likes - 1 : current.likes + 1,\n      liked: !current.liked,\n    })\n  );\n\n  function handleLike() {\n    startTransition(async () =&gt; {\n      addOptimistic();\n      await onToggle(post.id); \/\/ server call; parent owns the real state\n    });\n  }\n\n  return (\n    &lt;button onClick={handleLike}&gt;\n      {optimistic.liked ? &quot;\u2665&quot; : &quot;\u2661&quot;} {optimistic.likes}\n    &lt;\/button&gt;\n  );\n}\n<\/code><\/pre>\n<p>The part that took me a while to trust: there is no rollback code. None. When the async work settles, React discards the optimistic value and re-renders from the real state, whatever that turned out to be. If the server call succeeded and the parent updated props, the optimistic value and the real value match, so nothing visibly changes. If it failed, the UI snaps back to the truth on its own. The <a href=\"https:\/\/react.dev\/reference\/react\/useOptimistic\" rel=\"nofollow noopener\" target=\"_blank\">useOptimistic reference<\/a> describes this as showing a different state &ldquo;while an async action is underway&rdquo;, and that wording is more precise than it looks. More on that in a minute.<\/p>\n<p>My React 18 version, with the concurrency bugs actually fixed, ran about 60 lines. This one is about 20, and the 20 don&rsquo;t have the double-click bug, because React queues optimistic updates within transitions instead of letting them trample each other.<\/p>\n<h2 id=\"the-comment-feed-pattern\">The comment feed pattern<\/h2>\n<p>Toggles are the easy case. The pattern I use more in real work is the list append, like a comment feed where the new comment should appear instantly with a subtle &ldquo;sending&rdquo; treatment:<\/p>\n<pre><code class=\"language-jsx\">function Comments({ comments, postComment }) {\n  const [optimisticComments, addOptimisticComment] = useOptimistic(\n    comments,\n    (current, newComment) =&gt; [...current, { ...newComment, sending: true }]\n  );\n\n  async function submit(formData) {\n    const text = formData.get(&quot;text&quot;);\n    addOptimisticComment({ id: crypto.randomUUID(), text });\n    await postComment(text);\n  }\n\n  return (\n    &lt;&gt;\n      {optimisticComments.map((c) =&gt; (\n        &lt;p key={c.id} style={{ opacity: c.sending ? 0.5 : 1 }}&gt;\n          {c.text}\n        &lt;\/p&gt;\n      ))}\n      &lt;form action={submit}&gt;\n        &lt;input name=&quot;text&quot; \/&gt;\n      &lt;\/form&gt;\n    &lt;\/&gt;\n  );\n}\n<\/code><\/pre>\n<p>Because this uses a form action, there&rsquo;s no startTransition to write. Form actions run inside a transition automatically, which is why useOptimistic pairs so well with useActionState. I covered that hook in <a href=\"https:\/\/abrarqasim.com\/blog\/react-19-useactionstate-the-form-boilerplate-i-deleted\" rel=\"noopener\">my useActionState post<\/a>, and if you&rsquo;re adopting one of them, you&rsquo;ll probably end up adopting both. The <code>sending<\/code> flag is a nice bonus: your optimistic items can carry extra fields the real data doesn&rsquo;t have, so &ldquo;pending&rdquo; styling falls out for free instead of needing its own state.<\/p>\n<h2 id=\"the-two-things-that-bit-me\">The two things that bit me<\/h2>\n<p>First: the optimistic update has to happen inside a transition or an action. Call addOptimistic from a plain event handler and React logs a warning, then discards your update almost immediately. I lost half an hour to this because the code looked right and mostly worked, in the way that race conditions mostly work. If you&rsquo;re not using form actions, you need the startTransition wrapper from the earlier example. It&rsquo;s not optional decoration.<\/p>\n<p>Second: the optimistic state reverts when the action finishes, not when your data arrives. If your await resolves before the real state actually updates, say your mutation succeeds but the refetch lands two renders later, the UI flashes back to the old value in between. The fix is to make the awaited action itself responsible for updating the real state before it resolves. Next.js server actions with revalidation do this for you. Hand-rolled fetch code needs to await the refetch too, not just the mutation.<\/p>\n<h2 id=\"when-i-skip-it\">When I skip it<\/h2>\n<p>Optimistic UI is for actions that almost always succeed and are cheap to describe locally. Likes, follows, toggles, renames, adding a comment. I skip it anywhere failure is common or expensive to explain: payments, sending email, anything with server-side validation the client can&rsquo;t predict. Faking success on a checkout form isn&rsquo;t optimism, it&rsquo;s lying with extra steps.<\/p>\n<p>There&rsquo;s also a test from <a href=\"https:\/\/abrarqasim.com\/blog\/react-hooks-best-practices-2026-the-rules-i-actually-keep\" rel=\"noopener\">my React hooks best practices post<\/a> that applies here: if a hook call needs a paragraph of explanation next to it, the abstraction is wrong for that spot. useOptimistic passes the test for simple mutations and fails it for multi-step flows where the &ldquo;expected result&rdquo; depends on what the server decides. Trust the test.<\/p>\n<h2 id=\"try-it-this-week\">Try it this week<\/h2>\n<p>Pick one mutation in your app that currently shows a spinner. Move the server call into an action, wrap the instant update in useOptimistic, and delete the rollback code you were maintaining. Then open dev tools, throttle the network to slow 3G, and click around. The difference is not subtle. I do this kind of incremental modernization for client codebases fairly regularly (there&rsquo;s more about that on <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">my work page<\/a>), and this is the rare refactor where users notice the improvement the same day you ship it.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>React 19&#8217;s useOptimistic hook replaced my hand-rolled optimistic update code. The before and after, the rollback I deleted, and the two gotchas that bit me.<\/p>\n","protected":false},"author":2,"featured_media":570,"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 update code. The before and after, the rollback I deleted, and the two gotchas that bit me.","rank_math_focus_keyword":"useoptimistic","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[138,354],"tags":[265,41,43,194,68],"class_list":["post-571","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-frontend","category-react","tag-optimistic-ui","tag-react","tag-react-19","tag-react-hooks","tag-useoptimistic"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/571","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=571"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/571\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/570"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=571"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=571"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=571"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}