{"id":461,"date":"2026-07-15T05:02:53","date_gmt":"2026-07-15T05:02:53","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/react-19-useoptimistic-loading-spinners-i-finally-deleted\/"},"modified":"2026-07-15T05:02:53","modified_gmt":"2026-07-15T05:02:53","slug":"react-19-useoptimistic-loading-spinners-i-finally-deleted","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/react-19-useoptimistic-loading-spinners-i-finally-deleted\/","title":{"rendered":"React 19 useOptimistic: The Loading Spinners I Finally Deleted"},"content":{"rendered":"<p>Okay, quick story. About four months ago I shipped a comments feature for a client dashboard. User clicks Post, we send it to the server, wait for the round-trip, then render the comment. Standard stuff. And every single test user asked me the same thing during the walkthrough: &ldquo;Is it broken? Why is it lagging?&rdquo;<\/p>\n<p>It wasn&rsquo;t lagging. The round-trip was around 180ms. But 180ms of nothing happening felt slower than 800ms of something happening. So I did what everyone does. I built the optimistic update by hand. Rendered the pending comment with a fake ID, stashed the original state in a ref, wrote rollback logic for the failure case, prayed the state stayed in sync when a user posted three comments in a row.<\/p>\n<p>That code lived for two weeks. Then I ripped it out and used <code>useOptimistic<\/code>. If you&rsquo;re still hand-rolling optimistic updates in a React 19 app, this is the post I wish someone had shoved at me back in March.<\/p>\n<h2 id=\"what-useoptimistic-actually-does\">What useOptimistic actually does<\/h2>\n<p><code>useOptimistic<\/code> shipped as part of the <a href=\"https:\/\/react.dev\/blog\/2024\/12\/05\/react-19\" rel=\"nofollow noopener\" target=\"_blank\">React 19 release<\/a> and lets you show a temporary state during an async action, then automatically snap back to the real state when that action resolves. That&rsquo;s it. No state machine. No rollback code. The docs are pretty terse, so <a href=\"https:\/\/react.dev\/reference\/react\/useOptimistic\" rel=\"nofollow noopener\" target=\"_blank\">read them first<\/a> if you want the primary source.<\/p>\n<p>The signature reads like <code>useState<\/code> with an extra reducer:<\/p>\n<pre><code class=\"language-jsx\">const [optimistic, addOptimistic] = useOptimistic(\n  state,\n  (currentOptimistic, action) =&gt; nextOptimistic\n);\n<\/code><\/pre>\n<p>You pass the real state and a reducer that describes the temporary update. React shows <code>optimistic<\/code> while an action is pending, and swaps back to <code>state<\/code> when the transition finishes.<\/p>\n<p>The catch: it only works inside a transition. That means a form action, a call from <code>useActionState<\/code>, or an explicit <code>startTransition(() =&gt; ...)<\/code>. Call <code>addOptimistic<\/code> outside a transition and React logs a warning and does nothing. This tripped me up for a full afternoon.<\/p>\n<h2 id=\"the-before-code-id-rather-forget\">The before code I&rsquo;d rather forget<\/h2>\n<p>Here is roughly what my hand-rolled comment poster looked like. Cleaner than the original, still ugly.<\/p>\n<pre><code class=\"language-jsx\">function CommentBox({ postId }) {\n  const [comments, setComments] = useState([]);\n  const [pending, setPending] = useState(null);\n\n  async function submit(text) {\n    const tempId = crypto.randomUUID();\n    const snapshot = comments;\n    setPending({ id: tempId, text, status: 'sending' });\n    setComments([...comments, { id: tempId, text }]);\n    try {\n      const saved = await postComment(postId, text);\n      setComments((c) =&gt;\n        c.map((x) =&gt; (x.id === tempId ? saved : x))\n      );\n    } catch (err) {\n      setComments(snapshot);\n      toast.error('Comment failed');\n    } finally {\n      setPending(null);\n    }\n  }\n  \/\/ ...\n}\n<\/code><\/pre>\n<p>Every optimistic feature I built looked like this. A snapshot, a temp record, a swap on success, a revert on failure, a pending flag for the spinner. Multiply by five components in the same app and I had roughly 150 lines of &ldquo;not real business logic&rdquo; I was maintaining. The reverts were the worst part. If the user acted again before the first request resolved, the snapshot was stale and the rollback would erase the second comment too.<\/p>\n<h2 id=\"the-after-with-useoptimistic\">The after with useOptimistic<\/h2>\n<p>Here is the same feature with <code>useOptimistic<\/code> and a Next.js server action. I dropped the snapshot logic, the pending flag, and the manual swap.<\/p>\n<pre><code class=\"language-jsx\">'use client';\n\nimport { useOptimistic } from 'react';\nimport { postComment } from '.\/actions';\n\nexport function CommentBox({ postId, comments }) {\n  const [optimistic, addOptimistic] = useOptimistic(\n    comments,\n    (state, newComment) =&gt; [\n      ...state,\n      { ...newComment, pending: true },\n    ]\n  );\n\n  async function action(formData) {\n    const text = formData.get('text');\n    addOptimistic({ id: crypto.randomUUID(), text });\n    await postComment(postId, text);\n  }\n\n  return (\n    &lt;form action={action}&gt;\n      &lt;ul&gt;\n        {optimistic.map((c) =&gt; (\n          &lt;li key={c.id} className={c.pending ? 'opacity-50' : ''}&gt;\n            {c.text}\n          &lt;\/li&gt;\n        ))}\n      &lt;\/ul&gt;\n      &lt;input name=&quot;text&quot; \/&gt;\n      &lt;button&gt;Post&lt;\/button&gt;\n    &lt;\/form&gt;\n  );\n}\n<\/code><\/pre>\n<p>Notice what is gone: no <code>useState<\/code> for the list, no snapshot, no rollback branch, no pending flag as separate state. The <code>pending: true<\/code> marker lives inside the optimistic entry, so I can style pending items differently without a parallel data structure.<\/p>\n<p>The rollback happens for free. If <code>postComment<\/code> throws, the transition fails and React drops the optimistic entry. If the user posts three comments back to back, each queues into its own transition and resolves independently. No stale-snapshot bug because there&rsquo;s no snapshot to be stale.<\/p>\n<p>The one code-review complaint I got: no visible loading feedback on outright failure. Fixed with a try\/catch inside the action that fires a toast on error. React still handles the state part.<\/p>\n<h2 id=\"where-useoptimistic-quietly-saved-me\">Where useOptimistic quietly saved me<\/h2>\n<p>Three real cases from the last few months, roughly in order of how much time each one saved me.<\/p>\n<p><strong>Toggle switches on a settings page.<\/strong> Users flip a switch, we PATCH the setting, then re-render from the returned config. Old code had a race where fast toggling would flip back and forth as older responses landed after newer ones. <code>useOptimistic<\/code> serializes the optimistic view through the transition queue, so the last click wins visually and the server response reconciles cleanly.<\/p>\n<p><strong>&ldquo;Mark as read&rdquo; on a notification list.<\/strong> A user inbox with 40 notifications. Old code updated local state, then swapped after the batch mutation. Ugly loading skeleton whenever they marked 10 at once. <code>useOptimistic<\/code> marks them instantly, keeps them styled as &ldquo;just read&rdquo; until the request finishes, then lets the server version take over without a flash.<\/p>\n<p><strong>Delete-with-undo.<\/strong> This is the sneaky win. When I <code>addOptimistic({ deleted: true })<\/code> and render deleted items with a strikethrough and an Undo button, if the user hits Undo before the transition resolves, I can cancel the pending action and the optimistic state clears itself. No manual &ldquo;did the undo happen before the delete&rdquo; bookkeeping.<\/p>\n<p>If you want more of the framework side of this, I wrote about the <a href=\"https:\/\/abrarqasim.com\/blog\/nextjs-server-actions-when-i-stopped-writing-api-routes\/\" rel=\"noopener\">Next.js server actions pattern I actually ship<\/a> a few weeks back. <code>useOptimistic<\/code> pairs with server actions the way React Query pairs with fetch.<\/p>\n<h2 id=\"gotchas-i-hit-in-the-first-two-weeks\">Gotchas I hit in the first two weeks<\/h2>\n<p><strong>Only inside a transition.<\/strong> I called <code>addOptimistic<\/code> from a plain button click handler and React silently ignored it. Half an hour of &ldquo;why is nothing rendering&rdquo; before I read the fine print. You need a form <code>action<\/code>, <code>useActionState<\/code>, or an explicit <code>startTransition(() =&gt; ...)<\/code> wrapper.<\/p>\n<p><strong>The reducer runs on every render during the transition.<\/strong> If your reducer allocates a big array, you allocate it repeatedly. I moved a <code>.map<\/code> over the full list into the reducer once and burned a few frames on a large table. Keep the reducer O(k) where k is the size of your update, not the size of the whole list.<\/p>\n<p><strong>Server state must be authoritative.<\/strong> I tried once to use <code>useOptimistic<\/code> as a local-only &ldquo;pretend it saved&rdquo; hack for a form without any real server behind it. Don&rsquo;t. When the transition finishes and the passed-in <code>state<\/code> hasn&rsquo;t changed, React drops the optimistic update and your UI reverts. Use <code>useState<\/code> for local-only, not this hook.<\/p>\n<p><strong>It does not deduplicate.<\/strong> If you <code>addOptimistic<\/code> twice for the same logical action, you&rsquo;ll see two entries. I now generate the temp id from a stable key when I need dedupe.<\/p>\n<p><strong>TypeScript inference is picky.<\/strong> The reducer&rsquo;s <code>action<\/code> parameter defaults to the state type, which is almost never what you want. I annotate it explicitly:<\/p>\n<pre><code class=\"language-tsx\">useOptimistic&lt;Comment[], NewComment&gt;(comments, (state, action) =&gt; [\n  ...state,\n  { ...action, pending: true },\n]);\n<\/code><\/pre>\n<h2 id=\"when-i-still-dont-reach-for-it\">When I still don&rsquo;t reach for it<\/h2>\n<p>Two cases where I stick with a plain pattern instead.<\/p>\n<p>If the mutation is fire-and-forget and doesn&rsquo;t affect a visible list, <code>useOptimistic<\/code> is overkill. A regular <code>useTransition<\/code> with a <code>pending<\/code> flag is smaller and reads better.<\/p>\n<p>If I need the optimistic-looking state to last longer than the transition, I still use <code>useState<\/code>. <code>useOptimistic<\/code> snaps back the second the transition resolves. That is exactly what you want most of the time and exactly what you don&rsquo;t want when the flow is &ldquo;show the new item highlighted for three seconds after save completes&rdquo;. For that, I keep <code>useState<\/code> for the highlight and let <code>useOptimistic<\/code> handle only the placeholder.<\/p>\n<p>I also don&rsquo;t use it in server components. It&rsquo;s a client-only hook, and if you try, the build fails with a clear error. Not a real gotcha, just worth knowing before you refactor.<\/p>\n<h2 id=\"try-this-monday\">Try this Monday<\/h2>\n<p>If there is any place in your app where the user clicks something and then waits, take 20 minutes to try this:<\/p>\n<ol>\n<li>Find a small mutation with a visible list: a toggle on a settings page, a mark-as-read on notifications, a delete with confirmation, or an inline edit on a table row.<\/li>\n<li>Wrap the current call in a form action or <code>startTransition<\/code>.<\/li>\n<li>Replace the manual optimistic bookkeeping with <code>useOptimistic<\/code> and a reducer that appends a pending marker.<\/li>\n<li>Test the failure path by throwing on the server on purpose. Watch it revert cleanly.<\/li>\n<\/ol>\n<p>If it feels good, migrate one component at a time. That is how I did it. A month later, the <code>useState + snapshot + rollback<\/code> pattern was gone from the codebase and the loading-spinner tickets stopped showing up in QA. The companion piece on the <a href=\"https:\/\/abrarqasim.com\/blog\/react-19-use-hook-the-pattern-i-actually-reach-for-now\/\" rel=\"noopener\">React 19 use hook I now reach for<\/a> sits next to this one if you want more of the same. And if this &ldquo;here is what I actually ship&rdquo; tone is useful, more of it lives at <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">my portfolio and blog<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>How I replaced hand-rolled optimistic UI code with React 19&#8217;s useOptimistic hook. Real patterns, gotchas from two weeks of production, and when to skip it.<\/p>\n","protected":false},"author":2,"featured_media":460,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"How I replaced hand-rolled optimistic UI code with React 19's useOptimistic hook. Real patterns, gotchas from two weeks of production, and when to skip it.","rank_math_focus_keyword":"useoptimistic","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[138,354],"tags":[38,44,382,41,359,355,170,63,68],"class_list":["post-461","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-frontend","category-react","tag-frontend","tag-javascript","tag-optimistic-ui-2","tag-react","tag-react-19-2","tag-react-hooks-2","tag-server-actions-2","tag-typescript","tag-useoptimistic"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/461","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=461"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/461\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/460"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=461"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=461"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=461"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}