I am a...
Learn more
How it worksPricingFAQ
Account
May 8, 2026 · 11 min read · Cadence Editorial

How to implement optimistic UI in React

optimistic ui react — How to implement optimistic UI in React
Photo by [Antonio Batinić](https://www.pexels.com/@antonio-batinic-2573434) on [Pexels](https://www.pexels.com/photo/black-screen-with-code-4164418/)

How to implement optimistic UI in React

To implement optimistic UI in React, use the React 19 useOptimistic hook inside a startTransition or Server Action: render the assumed result instantly, await the mutation, and let React revert automatically if it throws. For REST stacks, TanStack Query's onMutate / onError / onSettled triple gives the same effect with cache snapshots. For GraphQL, Apollo's optimisticResponse does it in one prop. The right choice depends on your data layer, not your taste.

Optimistic UI is the difference between an app that feels native and one that feels like a website. It is also the fastest way to lie to a user. This guide is the playbook for getting both halves right.

What optimistic UI actually is (and what it isn't)

Optimistic UI means rendering the assumed-success state of a mutation before the network round-trip completes. Click a heart, the heart fills in instantly. Drag a card, it lands in the new column instantly. Send a message, it appears at the bottom of the thread instantly.

The technical mechanism is identical across patterns: capture the user intent, write it to local state, kick off the real mutation, and reconcile when the server replies. The hard parts are not the happy path. The hard parts are the rollback, the conflict, and knowing when to leave the spinner alone.

Linear's product team famously tracks input-to-paint latency under 50ms as a baseline for any interactive surface. Figma holds the same line. Anything above 100ms reads as laggy to a Hacker News user, which is the only audience that matters for B2B SaaS in 2026.

Why this matters more in 2026

Three things changed since the last serious round of "optimistic UI" articles. React 19 shipped useOptimistic as a first-class primitive in December 2024, so you no longer need to hand-roll the snapshot logic for every Action. Next.js 15 made Server Actions stable and cheap, which means your mutation, your form, and your optimistic state can live in the same file. Edge runtimes still hit 80-200ms even on cache hits, so the optimistic layer is what hides the runtime from the user.

The bar moved with the tooling. Users compare every B2B app to Linear now, and Linear is uncompromising about perceived latency. If your dashboard takes 300ms to update after a click, you look slow next to a competitor who hides the network entirely.

Four patterns, ranked by stack

There is no single right answer. There is the right answer for your data layer.

PatternBest forSetup costAuto-rollbackConflict handling
Manual useState + try/catch1-2 mutations, no shared cacheMinutesManual snapshotNone
TanStack QueryREST APIs with shared cache1 houronError restores snapshotRe-fetch in onSettled
Apollo / RelayGraphQL stacks2 hoursAutomatic on GraphQL errorCache normalization
useOptimistic + Server ActionsNext.js 15 / React 19 apps30 minAutomatic on Action throwServer-authoritative state

If you are starting a new app today on Next.js 15, the answer is useOptimistic plus Server Actions. If you have a TanStack Query codebase, do not rip it out. The two compose; we cover that below.

Pattern 1: Manual optimistic state with rollback

The smallest possible implementation. No dependencies, no hook, no library. You snapshot the previous value, set the optimistic value, fire the request, and roll back in the catch block.

'use client';
import { useState } from 'react';

export function LikeButton({ postId, initialLiked, initialCount }) {
  const [liked, setLiked] = useState(initialLiked);
  const [count, setCount] = useState(initialCount);

  async function toggleLike() {
    const prevLiked = liked;
    const prevCount = count;
    setLiked(!prevLiked);
    setCount(prevCount + (prevLiked ? -1 : 1));
    try {
      const res = await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
      if (!res.ok) throw new Error('like failed');
    } catch (err) {
      setLiked(prevLiked);
      setCount(prevCount);
      toast.error('Could not save your like. Try again.');
    }
  }

  return <button onClick={toggleLike}>{liked ? '♥' : '♡'} {count}</button>;
}

Where this breaks: stale closures over liked if the user clicks twice fast, lost updates if a websocket pushes a new count mid-flight, and a debugging headache the first time you have to reconcile three sources of truth in your head. Use this pattern only when you have one isolated mutation and no shared cache.

Pattern 2: TanStack Query optimistic updates

TanStack Query is still the standard for REST + cache in 2026 (the TanStack Query vs SWR comparison covers the cache trade-offs in depth). Its optimistic update pattern is older than useOptimistic and battle-tested across millions of apps. The trick is the onMutate / onError / onSettled triple.

const mutation = useMutation({
  mutationFn: updateTodo,
  onMutate: async (newTodo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    const previousTodos = queryClient.getQueryData(['todos']);
    queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
    return { previousTodos };
  },
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

onMutate cancels in-flight refetches so they cannot stomp the optimistic write, snapshots the cache, and writes the assumed value. The snapshot is returned as context, which onError uses to restore. onSettled then refetches the authoritative list, which silently corrects any drift between assumed and actual server state. This is the closest thing to a complete rollback story among the four patterns.

Pattern 3: Apollo or Relay optimisticResponse

GraphQL stacks get a slightly cleaner API because the response shape is part of the query. Pass optimisticResponse to mutate with the assumed payload, and Apollo writes it into the normalized cache for every component that reads the same fields.

addTodo({
  variables: { text: 'Ship the feature' },
  optimisticResponse: {
    addTodo: {
      __typename: 'Todo',
      id: 'temp-id',
      text: 'Ship the feature',
      completed: false,
    },
  },
});

The temp-id is critical. Apollo needs a stable cache key for the optimistic object, then swaps it for the canonical id when the server replies. If the mutation throws or returns a GraphQL error, Apollo discards the optimistic version automatically and the cache rolls back to the previous state. Relay works the same way with optimisticUpdater. You almost never write rollback code by hand on a GraphQL stack.

Pattern 4: React 19 useOptimistic + Server Actions (the new default)

useOptimistic is React 19's official answer for transient UI state during an async Action. The signature returns a tuple, like useState, plus an updater that only takes effect inside a startTransition or a Server Action callback.

'use client';
import { useOptimistic, startTransition } from 'react';

export function TodoList({ todos, addTodo }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (current, newTodo) => [...current, { ...newTodo, pending: true }],
  );

  async function action(formData) {
    const text = formData.get('text');
    startTransition(async () => {
      addOptimisticTodo({ id: crypto.randomUUID(), text });
      try {
        await addTodo(text);
      } catch (err) {
        toast.error('Could not save. Reverting.');
      }
    });
  }

  return (
    <>
      <ul>
        {optimisticTodos.map((t) => (
          <li key={t.id} className={t.pending ? 'opacity-50' : ''}>{t.text}</li>
        ))}
      </ul>
      <form action={action}><input name="text" /></form>
    </>
  );
}

The reducer overload (second argument) is what you want for lists, carts, and any case where the optimistic state is a function of the latest base state plus the action. If addTodo throws, React reverts optimisticTodos to the original todos automatically; you never call a setter to roll back. The try/catch exists only to surface the failure to the user, not to repair state.

The Server Action lives in a separate 'use server' file:

'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';

export async function addTodo(text: string) {
  await db.insert(todos).values({ text });
  revalidatePath('/todos');
}

For more on wiring Server Actions cleanly, see our guide to Server Actions in Next.js 15. For broader React performance wins around the same primitives, the React performance playbook for 2026 is the right next read.

Designing the failure path: toast + rollback animation

The default useOptimistic rollback is silent. The optimistic item disappears. That is technically correct and emotionally wrong. A user who sent a message and saw it vanish has no model for what just happened.

Three ingredients make rollback feel survivable:

  • A toast that names the action and offers a retry. Not "Error 500." Try "Could not send. Tap to retry."
  • A short animation back to the prior state (200-300ms ease-out), not an instant snap.
  • A persistent error indicator on the affected row until the user acknowledges or retries.

Sonner (the toast library shadcn uses) plus a try/catch around the optimistic dispatch covers the toast cleanly. For the animation, mark the rolled-back item with a data-rollback attribute for a single render and let CSS handle the rest. Linear's app does exactly this; if you watch a comment delete fail, the row fades back in over ~250ms with a red ring that fades after two seconds.

Conflict resolution for collaborative apps

Optimistic UI in a single-user app is a UX problem. Optimistic UI in a multi-user app is a distributed-systems problem.

The naive answer is last-write-wins: whoever's request lands at the server last overwrites the others. This is acceptable for a single-tenant todo list and unacceptable for anything where two users edit the same record. The classic failure mode: User A renames a project to "Q3 launch" optimistically; User B simultaneously renames it to "Q3 GTM"; whichever request the server processes second silently destroys the other person's work.

Three serious approaches:

  • Server-authoritative IDs and version vectors. Every mutation includes the version it was based on. The server rejects writes against stale versions and returns the current state, which clients reconcile. This is what Linear and Notion do for high-contention surfaces.
  • Operational transforms. Used by Google Docs and ShareJS. Mature, complex, and overkill for most apps.
  • CRDTs. Yjs and Automerge are the current standard. Liveblocks and Partykit ship managed CRDT runtimes that pair well with useOptimistic for the local render and a CRDT for the merged truth.

Pick the lightest option you can defend. Most product surfaces never need more than version vectors plus a clear "someone else changed this, refresh" toast.

Where optimistic UI is wrong

Optimism is a UX pattern, not a default. Four cases where you should always wait for the server:

  • Financial transactions. Never show "paid", "transferred", or "refunded" before the server confirms. The cost of a false confirmation is a support ticket and a chargeback.
  • Multi-step wizards with branching logic. If step 2 might branch based on step 1's server response, do not let the user start step 2 before step 1 confirms. You will paint a path you cannot honor.
  • Slow side-effects. Sending an email, transcoding a video, generating an export. Show progress, not success. "Sending..." with a spinner is honest; "Sent ✓" that becomes "Failed to send" 800ms later is not.
  • Anything with third-party side-effects. Stripe checkout, OAuth handoffs, webhook fires. The remote system has the only authoritative answer.

The rule of thumb: if the rollback is unrecoverable for the user, do not be optimistic. If you are not sure where on that spectrum your stack sits, the ship-or-skip stack audit gives you an honest grade in five minutes, including which mutations are safe to optimize and which should keep their spinner.

Steps

  1. Install React 19 and Next.js 15. Run npm i react@19 react-dom@19 next@15 in a Next.js 15 project (if you are still scaffolding the app, our guide to structuring a Next.js project for scale is worth a read first). useOptimistic ships in core; no extra dependency.
  2. Create the Server Action. Add a 'use server' file with the mutation function. Call your database driver (Drizzle, Prisma, raw SQL) and revalidatePath for the affected route.
  3. Wire useOptimistic in the client component. Mark the file 'use client'. Call useOptimistic(baseState, reducer) at the top, and pass the form's action prop a function that calls addOptimistic(...) then await action(...).
  4. Wrap dispatches in startTransition (when not inside a form action). If you are firing the optimistic update from a button click rather than a form submit, wrap the addOptimistic and await in startTransition(async () => { ... }).
  5. Add the failure path. Wrap the awaited Server Action in try/catch, surface a Sonner toast on error with a retry handler, and add a 250ms rollback animation via a data-rollback attribute and CSS.
  6. Test the rollback. In dev, throw inside the Server Action half the time. Verify the optimistic item reverts cleanly, the toast appears, and the retry handler re-runs the mutation. A short Playwright E2E test that intercepts the network request and forces a 500 makes this a one-line CI check.
  7. Add conflict handling for shared resources. If two users can edit the same row, include a version field in your mutation and reject stale writes server-side. Surface conflicts as a non-destructive toast, not a silent overwrite.

Optimistic UI is one of the highest-impact interactions a senior engineer can ship in a week. If your team is small and the feature is real, a Cadence senior engineer ($1,500/week) can typically pattern-match the right approach across your stack, ship the rollback UX, and write the conflict-resolution playbook in a single weekly cycle. Every Cadence engineer is AI-native by baseline (vetted on Cursor, Claude Code, and Copilot fluency in a voice interview before they unlock bookings), so the pairing on a hook this new is a non-issue. The platform's pool of 12,800 vetted engineers means most React 19 specs match in under two minutes.

Try it: audit your React stack with ship-or-skip for a one-page report on where optimistic UI will pay back fastest, or book a senior engineer for the week if you would rather have someone ship the playbook end-to-end. Weekly billing, 48-hour free trial, replace any week.

FAQ

Does useOptimistic replace TanStack Query for optimistic updates?

No. useOptimistic handles transient UI state for one Action; TanStack Query owns shared cache across components. Most production React apps in 2026 use both: TanStack Query for cache and refetch policy, useOptimistic for the in-flight render of a single mutation. They compose without conflict.

How do I roll back useOptimistic on error?

You don't call any rollback. If the awaited Action throws, useOptimistic automatically reverts to the base value on the next render. Catch the error to surface a toast or trigger a retry, but never call the optimistic setter again to "undo" the change.

Is optimistic UI ever a security risk?

Yes, when it implies a side-effect that has not happened. Never show "paid", "sent", or "permanently deleted" as confirmed before the server confirms. The user takes the UI as truth and acts on it. Treat optimism as a presentation layer, not a state machine.

Can I use useOptimistic in client components only?

It runs in client components, including inside Server Action callbacks invoked from a form. You cannot call it in a server component, because there is no client render to update. The pattern is: server component owns the data, passes it down, client component wraps it in useOptimistic.

What's the latency target for optimistic UI to feel native?

Under 100ms perceived input-to-paint. Linear and Figma target under 50ms. Once your action handler dispatches synchronously to setOptimistic, you are well inside that envelope, which is the entire point of the pattern: the network round-trip stops mattering for the perceived speed of the app.

All posts