EmpireUI
Get Pro
← Blog8 min read#optimistic updates#react#useOptimistic

Optimistic Updates in React: useOptimistic and the Pattern That Works

Learn how to implement optimistic updates in React using useOptimistic, manual rollback patterns, and mutation strategies that actually hold up in production.

developer writing React code on dark monitor with purple glow

What Optimistic Updates Actually Are

Optimistic updates are when you update the UI *before* the server confirms the action succeeded. User clicks like, the count jumps immediately. User deletes a row, it vanishes instantly. The server request fires in the background, and if it fails, you roll back. It's the difference between an app that feels snappy and one that feels like it's running on a DSL connection from 2003.

Honestly, most developers know the concept but underestimate how much the implementation details matter. The naive version — just mutate local state and hope the request succeeds — works until it doesn't. You need a coherent rollback story, error UI that doesn't confuse users, and a way to prevent race conditions when a user hammers a button.

The pattern has existed forever. Twitter was doing it before React was a thing. But React 19 shipped useOptimistic in early 2025, giving you a built-in hook that handles the temporary-state swap cleanly inside the concurrent rendering model. Worth knowing how it fits into the bigger picture before reaching for it.

The mental model: every mutation has three states — pending (optimistic UI shown, request in flight), success (server confirms, optimistic state becomes real), and error (request failed, roll back to previous state and show feedback). If your implementation doesn't consciously handle all three, you're shipping a bug.

useOptimistic: The Hook, Explained Without Hype

useOptimistic is a React 19 hook designed specifically for this flow. You give it your current state and an update function, and it gives you back a display value you can render optimistically while an async transition is in progress. The moment the transition completes — success or failure — it snaps back to the real state automatically.

Here's the canonical pattern. You've got a list of comments. User submits a new one. You want it to appear immediately at the bottom rather than waiting 200–400ms for the server roundtrip:

import { useOptimistic, useTransition } from 'react';

interface Comment {
  id: string;
  text: string;
  pending?: boolean;
}

function CommentList({ comments, addComment }: {
  comments: Comment[];
  addComment: (text: string) => Promise<Comment>;
}) {
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (state, newComment: Comment) => [...state, newComment],
  );
  const [isPending, startTransition] = useTransition();

  async function handleSubmit(text: string) {
    const tempId = crypto.randomUUID();
    startTransition(async () => {
      addOptimisticComment({ id: tempId, text, pending: true });
      await addComment(text);
    });
  }

  return (
    <ul>
      {optimisticComments.map((c) => (
        <li key={c.id} style={{ opacity: c.pending ? 0.6 : 1 }}>
          {c.text}
        </li>
      ))}
    </ul>
  );
}

A few things worth calling out here. First, addOptimisticComment only affects the displayed value — the real comments prop is untouched. Second, because it lives inside startTransition, React knows this is a non-urgent update and can keep the UI responsive during the async work. Third, if addComment throws, React resets optimisticComments to the original comments automatically. You don't write rollback code — the hook handles it.

That said, "automatic rollback" only means the *state* rolls back. You still need to catch the error and show a toast, re-enable a submit button, or whatever your UX calls for. The hook doesn't render error UI for you.

The Manual Pattern (Still Valid, Often Better)

Before useOptimistic, the standard approach was to save a snapshot of state before the mutation, optimistically update, and restore the snapshot on error. Libraries like React Query (now TanStack Query v5) expose this explicitly via onMutate, onError, and onSettled callbacks. If you're already on TanStack Query, this is still the pattern you want — it integrates with your caching layer.

import { useMutation, useQueryClient } from '@tanstack/react-query';

function useLikePost(postId: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: () => fetch(`/api/posts/${postId}/like`, { method: 'POST' }),

    onMutate: async () => {
      // Cancel any in-flight refetches so they don't overwrite our optimistic update
      await queryClient.cancelQueries({ queryKey: ['post', postId] });

      // Snapshot the current value
      const previous = queryClient.getQueryData(['post', postId]);

      // Optimistically update
      queryClient.setQueryData(['post', postId], (old: Post) => ({
        ...old,
        likes: old.likes + 1,
      }));

      return { previous };
    },

    onError: (_err, _vars, context) => {
      // Roll back to snapshot
      queryClient.setQueryData(['post', postId], context?.previous);
    },

    onSettled: () => {
      // Always refetch to sync with server truth
      queryClient.invalidateQueries({ queryKey: ['post', postId] });
    },
  });
}

The cancelQueries call before the optimistic update is easy to forget and it's the one that causes the most subtle bugs. Without it, an in-flight background refetch can complete *after* your optimistic update and overwrite it with stale server data. Your like count flickers. Users notice. Cancel first, always.

In practice, useOptimistic and TanStack Query are complementary, not competing. Use useOptimistic for component-local UI states (a textarea that shows your draft message while it uploads), and use TanStack Query's mutation callbacks when you need the optimistic data in your server cache and accessible across multiple components.

Quick aside: if you're on Next.js 15 with Server Actions, useOptimistic is the officially recommended approach because Server Actions integrate directly with useTransition and the React concurrent model. The TanStack pattern shines more in SPA setups with a separate API layer.

Race Conditions, Debouncing, and Edge Cases You'll Hit

What happens when a user likes a post, then un-likes it before the first request resolves? You now have two in-flight mutations touching the same resource. If they resolve out of order, your final displayed state is wrong. This is the race condition problem, and it's the part that bites teams in production.

The cleanest fix is to cancel the previous mutation before firing a new one. With TanStack Query you get one mutation instance per useMutation call — if you call .mutate() again while the previous is pending, it fires a new request but the previous one's onSuccess/onError are silently dropped (by default). That's mostly fine for toggle-style mutations, but you should test it explicitly.

// For toggle mutations, track intended state locally
function useLikeToggle(postId: string, initialLiked: boolean) {
  const [intendedLiked, setIntendedLiked] = React.useState(initialLiked);
  const controllerRef = React.useRef<AbortController | null>(null);

  async function toggle() {
    // Cancel previous in-flight request
    controllerRef.current?.abort();
    const controller = new AbortController();
    controllerRef.current = controller;

    const next = !intendedLiked;
    setIntendedLiked(next); // optimistic

    try {
      await fetch(`/api/posts/${postId}/like`, {
        method: next ? 'POST' : 'DELETE',
        signal: controller.signal,
      });
    } catch (err) {
      if ((err as Error).name !== 'AbortError') {
        setIntendedLiked(!next); // roll back only real errors
      }
    }
  }

  return { liked: intendedLiked, toggle };
}

Another edge case: what if the user navigates away while a mutation is in flight? In React 18+ with the concurrent model, unmounted transitions don't automatically abort their async work — the fetch still fires. You need useEffect cleanup or an AbortController tied to the component lifecycle if the in-flight request has side effects you care about.

Look, most of these edge cases don't matter for a blog post's like button. But in a collaborative document editor, a shopping cart, or anything financial? You need to think through all of them before shipping. The UI you build affects user trust directly — same principle behind why good component polish matters, whether you're working on interaction logic or the visual design layer of your app.

One more thing — optimistic updates interact badly with server-side validation that the client can't replicate. If your server rejects a comment for profanity, spam, or length limits, your optimistic comment will flash briefly and then disappear. That's jarring. Either mirror the validation logic client-side (worth the effort for character limits, not always worth it for complex rules), or don't go optimistic on those mutations.

Wiring Up Error UI That Doesn't Confuse Users

The rollback handles state. But users who watched their comment appear and then vanish need an explanation. "Something went wrong" is technically accurate and completely useless. Tell them what happened and what to do: "Couldn't post your comment — check your connection and try again." Keep the draft text. Give them a retry button.

function CommentForm({ onSubmit }: { onSubmit: (text: string) => Promise<void> }) {
  const [text, setText] = React.useState('');
  const [error, setError] = React.useState<string | null>(null);
  const [isPending, startTransition] = React.useTransition();

  function handleSubmit() {
    setError(null);
    startTransition(async () => {
      try {
        await onSubmit(text);
        setText(''); // Only clear on success
      } catch (err) {
        setError('Failed to post. Your text is saved — try again.');
        // text state is preserved so user doesn't lose their input
      }
    });
  }

  return (
    <div>
      <textarea value={text} onChange={(e) => setText(e.target.value)} />
      {error && <p role="alert" className="text-red-500 text-sm">{error}</p>}
      <button onClick={handleSubmit} disabled={isPending}>
        {isPending ? 'Posting…' : 'Post'}
      </button>
    </div>
  );
}

Notice the textarea value is *not* cleared until the request succeeds. This is the single most important UX detail in optimistic mutation flows and the one that gets dropped most often. Users lose written text and they don't come back. Preserve the draft, show the error, let them retry.

Honestly, the error states in optimistic UIs are where you see the difference between a team that's thought it through and one that shipped the happy path. Empire UI's component library includes form and notification components with built-in pending and error states — if you're building something production-facing, grab one of those rather than rolling the error UI from scratch every time.

Worth noting: if you want your UI to actually communicate state changes beautifully — transitions between pending, success, and error states with motion — look at how Empire UI handles component-level animation. The same principles that make gradient animations feel polished apply to state-driven micro-animations in your forms.

When to Reach for Optimistic Updates (and When Not To)

Use optimistic updates when: the operation is likely to succeed (~95%+ success rate), the operation is idempotent or easily reversible, and the latency without optimism noticeably degrades feel. Likes, follows, reacts, soft deletes, draft saves, reordering items — all strong candidates.

Skip optimistic updates when: the server does meaningful validation the client can't replicate, failure is common enough that frequent rollbacks would erode trust, or the operation is irreversible and high-stakes (payment processing, account deletion). For those cases, a loading spinner is more honest than a confident immediate update that might vanish.

The 150ms threshold is a useful rule of thumb — if your server consistently responds in under 150ms (co-located edge function, fast DB query), a subtle loading indicator is barely perceptible and may not be worth the complexity of optimistic state management. Measure first. Don't add complexity you don't need.

React 19's useOptimistic is a well-scoped tool. It's not a replacement for good caching strategy, not a substitute for fast APIs, and not a magic fix for apps that have deep architectural latency problems. It's a UX polish layer that makes responsive apps feel even better. Start with good server performance, then layer optimistic updates on top.

FAQ

Does useOptimistic automatically roll back on error?

Yes — when the async transition inside startTransition throws or rejects, React resets the optimistic state to the source-of-truth value you passed in. You still need to catch the error yourself and show appropriate UI feedback to the user.

Can I use useOptimistic with TanStack Query?

They solve slightly different problems. TanStack Query's onMutate/onError pattern is better when you need optimistic data in your server cache across multiple components. useOptimistic is cleaner for component-local UI state and integrates naturally with Server Actions in Next.js 15.

What React version do I need for useOptimistic?

useOptimistic shipped in React 19 (released late 2024). If you're on React 18, you can replicate the pattern manually with useState and try/catch rollback logic, or use TanStack Query's built-in optimistic mutation support.

How do I prevent race conditions with optimistic mutations?

Use AbortController to cancel previous in-flight requests before firing a new one, and call cancelQueries (if you're using TanStack Query) before applying your optimistic update. Track the user's *intended* state locally rather than trying to reconcile out-of-order server responses.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Optimistic UI in React: useOptimistic, Rollback and Error RecoveryNext.js Server Actions in 2026: Forms, Mutations and the Right PatternsUI Microinteractions in 2026: The Small Details That Make Users StayStepper Component in React: Multi-Step Forms and Onboarding