{"id":95,"date":"2026-04-17T05:03:21","date_gmt":"2026-04-17T05:03:21","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/?p=95"},"modified":"2026-04-17T05:03:21","modified_gmt":"2026-04-17T05:03:21","slug":"useoptimistic-react-19-hook-i-keep-forgetting","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/useoptimistic-react-19-hook-i-keep-forgetting\/","title":{"rendered":"useOptimistic is the React 19 hook I keep forgetting I have"},"content":{"rendered":"<p>Short version for the impatient: <code>useOptimistic<\/code> in React 19 lets you show a pretend &ldquo;done&rdquo; UI the instant the user clicks, then quietly reconcile with what the server actually says. It&rsquo;s not magic, it&rsquo;s not a replacement for proper state management, and I still forget it exists about once a week. If you want the long version and the mistakes I made so you don&rsquo;t have to, read on.<\/p>\n<p>I was building a little comment thread feature last Tuesday and caught myself writing the same awkward <code>setPending(true)<\/code> dance I&rsquo;ve written since 2018. Halfway through, I remembered React 19 shipped with a hook designed exactly for this. I had to go re-read the docs because I kept confusing it with <code>useTransition<\/code>. So this post is the cheat sheet I wish I&rsquo;d written for myself.<\/p>\n<h2 id=\"what-useoptimistic-actually-does\">What useOptimistic actually does<\/h2>\n<p>The hook gives you a second, temporary copy of your state that you can mutate instantly while an async update is in flight. When the real update resolves (or fails), the optimistic copy gets thrown away and React snaps back to whatever the real state is.<\/p>\n<p>The API is small:<\/p>\n<pre><code class=\"language-jsx\">const [optimisticState, addOptimistic] = useOptimistic(\n  actualState,\n  (currentState, optimisticValue) =&gt; nextState\n);\n<\/code><\/pre>\n<p>That second argument is a reducer. It&rsquo;s called with the current optimistic state and whatever value you pass to <code>addOptimistic<\/code>, and you return the new optimistic state. Same shape as <code>useReducer<\/code>, just scoped to the optimistic layer.<\/p>\n<p>The bit that tripped me up the first time: <code>addOptimistic<\/code> only works inside a transition. If you call it from a plain event handler with no async work around it, React throws. In practice that means you call it right before (or inside) a Server Action or <code>startTransition<\/code> block. The <a href=\"https:\/\/react.dev\/reference\/react\/useOptimistic\" rel=\"nofollow noopener\" target=\"_blank\">React docs for useOptimistic<\/a> spell this out, but I missed it on first read.<\/p>\n<h2 id=\"the-beforeafter-that-sold-me\">The before\/after that sold me<\/h2>\n<p><img decoding=\"async\" alt=\"useOptimistic is the React 19 hook I keep forgetting I have\" src=\"https:\/\/abrarqasim.com\/blog\/wp-content\/uploads\/2026\/04\/useoptimistic-react-19-hook-i-keep-forgetting-inline-1776402102.png\"><\/p>\n<p>Here&rsquo;s the old way I was writing an optimistic like button. It works, but look at how much ceremony there is for &ldquo;show the heart as red immediately&rdquo;:<\/p>\n<pre><code class=\"language-jsx\">\/\/ React 18 way\nfunction LikeButton({ postId, initialLiked, initialCount }) {\n  const [liked, setLiked] = useState(initialLiked);\n  const [count, setCount] = useState(initialCount);\n  const [pending, setPending] = useState(false);\n\n  async function handleClick() {\n    const prevLiked = liked;\n    const prevCount = count;\n    setLiked(!liked);\n    setCount(liked ? count - 1 : count + 1);\n    setPending(true);\n    try {\n      await fetch(`\/api\/like\/${postId}`, { method: 'POST' });\n    } catch (e) {\n      setLiked(prevLiked);\n      setCount(prevCount);\n    } finally {\n      setPending(false);\n    }\n  }\n\n  return &lt;button onClick={handleClick} disabled={pending}&gt;{liked ? '\u2665' : '\u2661'} {count}&lt;\/button&gt;;\n}\n<\/code><\/pre>\n<p>Three <code>useState<\/code> calls, manual rollback, and I have to remember to snapshot the previous values before I mutate. I got this wrong for two weeks on a side project because I forgot the rollback path on a network error and users saw phantom likes.<\/p>\n<p>Here&rsquo;s the same thing with <code>useOptimistic<\/code> and a Server Action:<\/p>\n<pre><code class=\"language-jsx\">\/\/ React 19 way\nimport { useOptimistic } from 'react';\nimport { toggleLike } from '.\/actions';\n\nfunction LikeButton({ postId, liked, count }) {\n  const [optimistic, addOptimistic] = useOptimistic(\n    { liked, count },\n    (state) =&gt; ({ liked: !state.liked, count: state.liked ? state.count - 1 : state.count + 1 })\n  );\n\n  async function handleClick() {\n    addOptimistic(null);\n    await toggleLike(postId);\n  }\n\n  return (\n    &lt;form action={handleClick}&gt;\n      &lt;button&gt;{optimistic.liked ? '\u2665' : '\u2661'} {optimistic.count}&lt;\/button&gt;\n    &lt;\/form&gt;\n  );\n}\n<\/code><\/pre>\n<p>No rollback code. If <code>toggleLike<\/code> throws, React discards the optimistic value and renders whatever the real <code>liked<\/code>\/<code>count<\/code> props are (which come from the server on the next render). The rollback is the absence of a commit.<\/p>\n<p>That&rsquo;s the trick worth internalizing: optimistic state isn&rsquo;t &ldquo;state you update&rdquo;. It&rsquo;s &ldquo;a derived view of real state plus a pending action&rdquo;. If the action never commits, the view never changes.<\/p>\n<h2 id=\"the-task-list-example-with-actual-subtleties\">The task list example, with actual subtleties<\/h2>\n<p>The canonical example in every tutorial is a task list. I&rsquo;ll do it too, but I&rsquo;ll show the bit the tutorials skip: what happens when the user clicks three times in a row.<\/p>\n<pre><code class=\"language-jsx\">function TodoList({ todos, addTodoAction }) {\n  const [optimisticTodos, addOptimisticTodo] = useOptimistic(\n    todos,\n    (current, newTodo) =&gt; [...current, { ...newTodo, pending: true }]\n  );\n\n  async function formAction(formData) {\n    const text = formData.get('text');\n    addOptimisticTodo({ id: crypto.randomUUID(), text });\n    await addTodoAction(text);\n  }\n\n  return (\n    &lt;&gt;\n      &lt;form action={formAction}&gt;\n        &lt;input name=&quot;text&quot; \/&gt;\n        &lt;button&gt;Add&lt;\/button&gt;\n      &lt;\/form&gt;\n      &lt;ul&gt;\n        {optimisticTodos.map((t) =&gt; (\n          &lt;li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}&gt;{t.text}&lt;\/li&gt;\n        ))}\n      &lt;\/ul&gt;\n    &lt;\/&gt;\n  );\n}\n<\/code><\/pre>\n<p>If the user smashes the Add button three times quickly, you get three concurrent Server Action calls. React queues the optimistic updates: each one is applied on top of the current optimistic state, in call order. When the first server response comes back, React re-runs the reducer from the new <code>todos<\/code> base with the remaining in-flight updates layered on top. You don&rsquo;t have to manage this. It&rsquo;s genuinely one of the nicer things about the design.<\/p>\n<p>What you do have to manage is keys. If you generate IDs on the client (<code>crypto.randomUUID()<\/code>), the server&rsquo;s response will come back with a different ID, and React will unmount and remount the <code>&lt;li&gt;<\/code>. That&rsquo;s a wasted render and it&rsquo;ll kill any CSS transitions you had on the row. Either have the server accept client-generated IDs, or use a separate client-only key for the pending row.<\/p>\n<h2 id=\"where-it-doesnt-fit\">Where it doesn&rsquo;t fit<\/h2>\n<p>I keep seeing people reach for <code>useOptimistic<\/code> when they should be using local state or <code>useTransition<\/code>. A few honest warnings:<\/p>\n<ul>\n<li><strong>If your mutation has no server round-trip, you don&rsquo;t need this.<\/strong> Just use <code>useState<\/code>. Optimistic UI is specifically the pattern for &ldquo;show the result before the server confirms&rdquo;.<\/li>\n<li><strong>If the mutation result isn&rsquo;t predictable<\/strong>, skip it. A form that returns a freshly-calculated discount code can&rsquo;t be optimistically rendered because you don&rsquo;t know the answer.<\/li>\n<li><strong>If you need to roll back to a specific intermediate state<\/strong>, this isn&rsquo;t the tool. The rollback is always &ldquo;drop the optimistic layer and show the real state&rdquo;. Anything more complex needs a reducer.<\/li>\n<li><strong>If you&rsquo;re not using Server Actions or transitions<\/strong>, you&rsquo;re fighting the API. It&rsquo;ll throw. The <a href=\"https:\/\/nextjs.org\/docs\/app\/building-your-application\/data-fetching\/server-actions-and-mutations\" rel=\"nofollow noopener\" target=\"_blank\">Next.js Server Actions docs<\/a> are the easiest way to wire this up if you&rsquo;re on the App Router.<\/li>\n<\/ul>\n<p>I wrote more about how the new compiler changes what code you even need to write around these hooks in <a href=\"https:\/\/abrarqasim.com\/blog\/react-compiler-what-it-does-to-your-code\/\" rel=\"noopener\">my post on the React Compiler<\/a> \u2014 worth a read if you haven&rsquo;t looked at it yet.<\/p>\n<h2 id=\"the-bug-i-hit-in-production\">The bug I hit in production<\/h2>\n<p>Because this is a real post and not a marketing piece, here&rsquo;s what actually went wrong when I first shipped this. I had an optimistic &ldquo;delete&rdquo; action on a list of messages. The reducer filtered out the deleted message. Worked great locally. In production, the server action was wrapped in an auth check that sometimes took 400ms on cold starts. During that 400ms, if another message arrived via websocket and triggered a re-render with new <code>messages<\/code> props, the optimistic layer was re-applied from the new base \u2014 correctly filtering the deleted one. Also correctly showing the new message. So far, so good.<\/p>\n<p>The bug: my reducer was doing <code>current.filter(m =&gt; m.id !== deletedId)<\/code>. If the deleted message was the new one that had just arrived, the filter removed it. If it wasn&rsquo;t, the filter was a no-op, and the UI showed the &ldquo;deleted&rdquo; message for 400ms until the server caught up. I fixed it by making the reducer aware that a delete had been requested and rendering those rows with a &ldquo;disappearing&rdquo; style regardless of whether the server had confirmed yet.<\/p>\n<p>The lesson: your optimistic reducer runs every time the real state changes, not just once. Treat it like a pure projection, not a one-shot side effect. Dan Abramov&rsquo;s <a href=\"https:\/\/react.dev\/blog\/2024\/04\/25\/react-19\" rel=\"nofollow noopener\" target=\"_blank\">original React 19 announcement post<\/a> is worth re-reading once you&rsquo;ve hit a bug like this \u2014 the concurrent-update section makes more sense after you&rsquo;ve been burned.<\/p>\n<h2 id=\"what-to-try-this-week\">What to try this week<\/h2>\n<p>Pick one form in your app that currently has a loading spinner and a disabled submit button. Replace that whole dance with <code>useOptimistic<\/code> and a Server Action. Time yourself. My rough rule: if the conversion takes more than twenty minutes, you&rsquo;re fighting the data flow and should probably refactor that component first. If it takes five, you&rsquo;ve found a place the hook was designed for, and you should go look for more of those.<\/p>\n<p>If you want to see how I approach this stuff in larger projects, I keep notes and examples in <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">my work portfolio<\/a> \u2014 most of the recent builds use Server Actions as the default mutation path, and <code>useOptimistic<\/code> in maybe a third of forms. Not everywhere. The trick is knowing when it earns its keep.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>A practical tour of React 19&#8217;s useOptimistic hook with before\/after code for a like button, a task list, and the gotchas I hit in production.<\/p>\n","protected":false},"author":2,"featured_media":93,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"A practical tour of React 19's useOptimistic hook with before\/after code for a like button, a task list, and the gotchas I hit in production.","rank_math_focus_keyword":"useoptimistic react example","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[35],"tags":[38,69,41,43,62,68],"class_list":["post-95","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-web-development","tag-frontend","tag-hooks","tag-react","tag-react-19","tag-server-actions","tag-useoptimistic"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/95","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=95"}],"version-history":[{"count":1,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/95\/revisions"}],"predecessor-version":[{"id":96,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/95\/revisions\/96"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/93"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=95"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=95"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=95"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}