
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.
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.
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.
There is no single right answer. There is the right answer for your data layer.
| Pattern | Best for | Setup cost | Auto-rollback | Conflict handling |
|---|---|---|---|---|
Manual useState + try/catch | 1-2 mutations, no shared cache | Minutes | Manual snapshot | None |
| TanStack Query | REST APIs with shared cache | 1 hour | onError restores snapshot | Re-fetch in onSettled |
| Apollo / Relay | GraphQL stacks | 2 hours | Automatic on GraphQL error | Cache normalization |
useOptimistic + Server Actions | Next.js 15 / React 19 apps | 30 min | Automatic on Action throw | Server-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.
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.
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.
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.
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.
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:
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.
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:
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.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.
Optimism is a UX pattern, not a default. Four cases where you should always wait for the server:
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.
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.'use server' file with the mutation function. Call your database driver (Drizzle, Prisma, raw SQL) and revalidatePath for the affected route.'use client'. Call useOptimistic(baseState, reducer) at the top, and pass the form's action prop a function that calls addOptimistic(...) then await action(...).addOptimistic and await in startTransition(async () => { ... }).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.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.
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.
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.
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.
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.
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.