{"id":228,"date":"2026-05-13T13:04:59","date_gmt":"2026-05-13T13:04:59","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/optimistic-ui-react-19-useoptimistic-six-months-in\/"},"modified":"2026-05-13T13:04:59","modified_gmt":"2026-05-13T13:04:59","slug":"optimistic-ui-react-19-useoptimistic-six-months-in","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/optimistic-ui-react-19-useoptimistic-six-months-in\/","title":{"rendered":"Optimistic UI in React 19: useOptimistic After Six Months in Production"},"content":{"rendered":"<p>Short version for the impatient: <code>useOptimistic<\/code> is the first React API that made my optimistic update code shorter without making the failure cases worse. I shipped it in three places over the last six months. I&rsquo;d do it again in two of them. If you want to know why, and where it doesn&rsquo;t fit, read on.<\/p>\n<p>I had a perfectly fine pattern for optimistic UI before this. A <code>useReducer<\/code>, a &ldquo;pending&rdquo; item array, an <code>onError<\/code> that reverted, a <code>useEffect<\/code> to reconcile with the server response. It worked. It was also somewhere around forty lines per feature, and I copied it. Three times. With slightly different bugs each time.<\/p>\n<p>Then I tried <code>useOptimistic<\/code> on a comment box, deleted twenty of those lines, and stopped pretending I was going to write a generic hook for it.<\/p>\n<h2 id=\"what-useoptimistic-actually-does\">What useOptimistic actually does<\/h2>\n<p>The official version, from the <a href=\"https:\/\/react.dev\/reference\/react\/useOptimistic\" rel=\"nofollow noopener\" target=\"_blank\">React docs<\/a>, is that <code>useOptimistic<\/code> lets you show a different state while an async action is underway. Fine. That&rsquo;s the polite description.<\/p>\n<p>The version I&rsquo;d give a junior dev on my team: it&rsquo;s a <code>useReducer<\/code> that automatically resets when the surrounding transition completes. The current state is whatever the server most recently returned. The &ldquo;optimistic&rdquo; state is what the user sees during a <code>startTransition<\/code>. When the transition is done, the optimistic state is gone, replaced by whatever your server gave you. You don&rsquo;t write any rollback code. You don&rsquo;t have to remember to clean up. The framework does both.<\/p>\n<p>That&rsquo;s the whole feature. The trick is figuring out where it actually helps.<\/p>\n<h2 id=\"before-my-old-optimistic-comment-list\">Before: my old optimistic comment list<\/h2>\n<p>Here&rsquo;s a stripped version of what I had in the codebase before. A list of comments where a user can add one, and we want the new comment to appear right away:<\/p>\n<pre><code class=\"language-jsx\">function CommentList({ initialComments, postId }) {\n  const [comments, setComments] = useState(initialComments);\n  const [pending, setPending] = useState([]);\n\n  async function addComment(text) {\n    const tempId = crypto.randomUUID();\n    const optimistic = { id: tempId, text, author: &quot;me&quot;, status: &quot;pending&quot; };\n    setPending((p) =&gt; [...p, optimistic]);\n    try {\n      const saved = await api.createComment(postId, text);\n      setComments((c) =&gt; [...c, saved]);\n    } catch (err) {\n      toast.error(&quot;Couldn't post that. Try again?&quot;);\n    } finally {\n      setPending((p) =&gt; p.filter((c) =&gt; c.id !== tempId));\n    }\n  }\n\n  const all = [...comments, ...pending];\n  return &lt;List items={all} onSubmit={addComment} \/&gt;;\n}\n<\/code><\/pre>\n<p>It works. I shipped it for two years. The problems weren&rsquo;t visible until I tried to add a second optimistic feature, deletes, and realized I needed a parallel <code>pendingDeletes<\/code> array, a way to filter the visible list against both, and a careful order so the optimistic delete didn&rsquo;t get clobbered by an in-flight create. That&rsquo;s where it got messy.<\/p>\n<h2 id=\"after-the-useoptimistic-version\">After: the useOptimistic version<\/h2>\n<pre><code class=\"language-jsx\">import { useOptimistic, useState, startTransition } from &quot;react&quot;;\n\nfunction CommentList({ initialComments, postId }) {\n  const [comments, setComments] = useState(initialComments);\n  const [optimistic, addOptimistic] = useOptimistic(\n    comments,\n    (state, newComment) =&gt; [...state, { ...newComment, status: &quot;pending&quot; }]\n  );\n\n  async function addComment(text) {\n    startTransition(async () =&gt; {\n      addOptimistic({ id: crypto.randomUUID(), text, author: &quot;me&quot; });\n      try {\n        const saved = await api.createComment(postId, text);\n        setComments((c) =&gt; [...c, saved]);\n      } catch (err) {\n        toast.error(&quot;Couldn't post that. Try again?&quot;);\n      }\n    });\n  }\n\n  return &lt;List items={optimistic} onSubmit={addComment} \/&gt;;\n}\n<\/code><\/pre>\n<p>A few things to notice. There&rsquo;s no <code>pending<\/code> array. There&rsquo;s no rollback. If the API throws, the <code>startTransition<\/code> ends, the optimistic state evaporates, and the list goes back to whatever <code>comments<\/code> holds. The toast tells the user what happened. That&rsquo;s it.<\/p>\n<p>The thing I didn&rsquo;t expect: I removed every <code>useEffect<\/code> I had in this component. The optimistic state derives from the canonical state, so there&rsquo;s nothing to sync. That alone made the next bug report a lot easier to read.<\/p>\n<p>I cover the matching pattern for forms in my post on <a href=\"https:\/\/abrarqasim.com\/blog\/react-useactionstate-hook-that-replaced-my-form-reducers\/\" rel=\"noopener\">useActionState replacing form reducers<\/a>, if you&rsquo;re stacking these React 19 hooks together. The two pair up well.<\/p>\n<h2 id=\"where-it-doesnt-fit\">Where it doesn&rsquo;t fit<\/h2>\n<p>Three places I tried it and walked back.<\/p>\n<p>The first one: long-running mutations. If your API takes more than about a second, the optimistic state sits there feeling stale. The user sees their comment appear, then a beat of nothing, then the list reorders when the server replies. I added a subtle &ldquo;saving&rdquo; indicator on the optimistic row, which helped, but at that point I was back to tracking a status field. Not worse than before. Just not the win it had been on fast endpoints.<\/p>\n<p>The second one: multi-step transactions. We had a &ldquo;move card to column&rdquo; action that fires three requests in sequence. Update the card. Update the source column. Update the destination column. <code>useOptimistic<\/code> is one reducer over one state. If you want to optimistically reflect &ldquo;card moved&rdquo; across three pieces of state, you either lift them into one combined reducer (annoying) or run three <code>useOptimistic<\/code> hooks (also annoying, and the rollback semantics get fuzzy when one of the three fails). I went back to TanStack Query&rsquo;s <a href=\"https:\/\/tanstack.com\/query\/latest\/docs\/framework\/react\/guides\/optimistic-updates\" rel=\"nofollow noopener\" target=\"_blank\">optimistic updates pattern<\/a> for this one. Their rollback model is more honest about partial failures.<\/p>\n<p>The third one: anything outside a transition. The hook only updates the optimistic state when you call <code>addOptimistic<\/code> inside <code>startTransition<\/code>. If you&rsquo;re trying to use it from an effect that fires on mount, or from a non-transition click handler, you get nothing. The error from React is clear. The misuse is easy to commit anyway.<\/p>\n<h2 id=\"what-i-keep-around-it\">What I keep around it<\/h2>\n<p>A small failure-handling pattern I now reach for every time.<\/p>\n<pre><code class=\"language-jsx\">const [optimistic, addOptimistic] = useOptimistic(items, (state, action) =&gt; {\n  if (action.type === &quot;add&quot;) {\n    return [...state, { ...action.item, status: &quot;pending&quot; }];\n  }\n  if (action.type === &quot;remove&quot;) {\n    return state.filter((i) =&gt; i.id !== action.id);\n  }\n  return state;\n});\n<\/code><\/pre>\n<p>Two things I do here on top of the docs example. The reducer takes a small action object instead of a raw item, so the same hook handles add and remove without two separate hooks. And every optimistic row carries a <code>status<\/code> field that the row component uses to dim it slightly. Users notice the dim. They notice nothing about the rollback. That tradeoff matters: people forgive a slow round trip if you tell them it&rsquo;s happening.<\/p>\n<p>I also keep a <code>try \/ catch<\/code> inside <code>startTransition<\/code> and surface errors with a toast. Don&rsquo;t skip the toast. The optimistic state will quietly revert and the user will think they hit &ldquo;post&rdquo; and the click missed.<\/p>\n<p>The React 19 release post lays out the design rationale if you want the underlying argument: see the <a href=\"https:\/\/react.dev\/blog\/2024\/12\/05\/react-19\" rel=\"nofollow noopener\" target=\"_blank\">React 19 announcement<\/a>. I had to read it twice before I trusted the automatic cleanup story. Worth it.<\/p>\n<h2 id=\"what-id-do-this-week-if-youre-starting\">What I&rsquo;d do this week if you&rsquo;re starting<\/h2>\n<p>Pick one feature where you currently fake an optimistic update with <code>setState<\/code> and a <code>try \/ catch<\/code>. A like button, a comment box, a row toggle. Convert that one feature only. Don&rsquo;t try to do a sweeping refactor across the app. The hook is small enough that you can wrap it in a <code>useTransition<\/code> and ship it in an afternoon.<\/p>\n<p>After a week of usage in one spot, you&rsquo;ll have opinions. Mine, after six months: I use it on anything where the request returns in under 800ms, where there&rsquo;s a single piece of state to touch, and where the error case is &ldquo;show a toast and forget.&rdquo; For everything else, the older <a href=\"https:\/\/tanstack.com\/query\/latest\/docs\/framework\/react\/guides\/optimistic-updates\" rel=\"nofollow noopener\" target=\"_blank\">TanStack Query optimistic update patterns<\/a> still beat it. That&rsquo;s not a knock on <code>useOptimistic<\/code>. It&rsquo;s a small, focused API that does one job well. Recognizing that scope is most of the win.<\/p>\n<p>If you want more on the React 19 hooks I use day to day, I write up the ones I actually keep across my work at <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">abrarqasim.com<\/a>. The list is shorter than the headline release notes suggest, and that&rsquo;s been the better way to learn the new API: small steps, in production, one feature at a time.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>useOptimistic in React 19 is the first optimistic UI API I actually shipped. Six months of production lessons: where it fits, where it doesn&#8217;t, what to keep.<\/p>\n","protected":false},"author":2,"featured_media":227,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"useOptimistic in React 19 is the first optimistic UI API I actually shipped. Six months of production lessons: where it fits, where it doesn't, what to keep.","rank_math_focus_keyword":"optimistic ui","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[138,35],"tags":[38,69,44,265,41,43,68],"class_list":["post-228","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-frontend","category-web-development","tag-frontend","tag-hooks","tag-javascript","tag-optimistic-ui","tag-react","tag-react-19","tag-useoptimistic"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/228","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=228"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/228\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/227"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=228"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=228"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=228"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}