Optimistic UI in React: useOptimistic, Rollback and Error Recovery
Learn how React 19's useOptimistic hook wires up instant UI feedback, automatic rollback on failure, and clean error recovery without the ceremony.
What Optimistic UI Actually Means
The idea is simple: you update the UI the moment the user acts, before the server responds, and then reconcile with reality once the response lands. That 200–400ms gap between click and server round-trip is invisible to users when you do this well. It's not when you don't.
Honestly, the pattern isn't new — Twitter was doing this with tweets back in 2013, and Gmail has been doing it with "Send" for over a decade. What's new is that React 19 gives you a first-class hook for it instead of making you juggle local state, useReducer, and a manual rollback mechanism yourself.
Worth noting: optimistic UI only makes sense when your mutations are "likely to succeed." Like-toggling, form saves, reordering a list — those are good candidates. Payments or irreversible deletes? You probably want a confirmation step instead.
Before useOptimistic, you'd typically keep a local copy of the pending state in a useState, render that, and on error restore the previous value. It works, but it's fragile. Every mutation needs its own copy-rollback dance and you end up with subtle bugs when concurrent mutations arrive faster than your server can respond.
useOptimistic: The Basics
useOptimistic shipped stable in React 19.0.0 (released early 2025). The hook takes two arguments: the current state value and an update function that merges the optimistic delta onto it. It returns a tuple — the optimistic value to render, and a trigger function.
import { useOptimistic, useTransition } from 'react';
type Message = { id: string; text: string; pending?: boolean };
function MessageList({ messages }: { messages: Message[] }) {
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
(state, newMsg: Message) => [...state, { ...newMsg, pending: true }]
);
const [isPending, startTransition] = useTransition();
async function handleSend(text: string) {
const tempId = crypto.randomUUID();
startTransition(async () => {
addOptimistic({ id: tempId, text });
await sendMessage(text); // real server call
});
}
return (
<ul>
{optimisticMessages.map((m) => (
<li key={m.id} style={{ opacity: m.pending ? 0.6 : 1 }}>
{m.text}
</li>
))}
</ul>
);
}The critical part: addOptimistic must be called inside a startTransition callback. React needs to know this is a non-urgent update that can be superseded. If you call it outside a transition, you'll get a runtime warning in development — and you should, because the rollback semantics won't work correctly.
Once the async function inside startTransition settles — either resolved or rejected — React automatically replaces the optimistic state with whatever messages prop holds at that point. You don't wire up rollback manually. The hook owns that.
Quick aside: useOptimistic doesn't manage async state itself. It's purely a UI layer. You still need to call your actual mutation (fetch, server action, tRPC call — whatever you're using) and let the parent's data refresh handle re-rendering with real data.
Rollback: How It Actually Works
Rollback is the part developers get confused about first. It feels like magic, but it's mechanical. The optimistic value is ephemeral — it only exists while the transition is in-flight. The moment the transition completes (success or error), React discards the optimistic layer and falls back to the "source of truth" value you passed as the first argument.
That means your rollback strategy is really about what state the parent holds after a failed mutation. If you're using a server component that re-fetches on every render, a failed action + rerender gets you rollback for free. If you're managing local client state, you need to make sure you're NOT updating that local state before the mutation confirms.
// Pattern: don't update local state until mutation succeeds
async function handleLike(postId: string) {
startTransition(async () => {
// Only the optimistic layer changes immediately
addOptimistic(postId);
try {
await toggleLike(postId);
// On success: parent state updates via revalidation
// optimistic layer naturally replaced with real data
} catch (err) {
// On failure: optimistic layer rolls back automatically
// because transition ended and parent state didn't change
setError('Like failed — try again');
}
});
}In practice, the cleanest setup pairs useOptimistic with React Server Actions and revalidatePath / revalidateTag. The server action runs, the cache invalidates, the server component re-renders with fresh data, and the optimistic layer is replaced cleanly. Zero manual rollback code.
That said, if you're on the client-only side (a SPA or fetching from a REST API), you'll want a pattern where your source-of-truth state only updates on confirmed success. Something like TanStack Query's onMutate / onError / onSettled trio does exactly this — but useOptimistic gives you an in-React equivalent without adding another library.
Error Recovery and User Feedback
Rolling back the UI silently is the wrong move. Users clicked something. They need to know it failed. A 400ms flicker where the UI snaps back and shows nothing is worse UX than just waiting for the response. So error recovery has two parts: the mechanical rollback (which useOptimistic handles) and the user-facing feedback (which you handle).
function LikeButton({ post }: { post: Post }) {
const [error, setError] = useState<string | null>(null);
const [optimisticLiked, setOptimisticLiked] = useOptimistic(
post.liked,
(_state, value: boolean) => value
);
const [, startTransition] = useTransition();
async function handleClick() {
setError(null);
startTransition(async () => {
setOptimisticLiked(!post.liked);
try {
await toggleLikeAction(post.id);
} catch {
setError('Could not save your like. Tap to retry.');
// optimistic state rolls back automatically here
}
});
}
return (
<div>
<button onClick={handleClick} aria-pressed={optimisticLiked}>
{optimisticLiked ? '♥ Liked' : '♡ Like'}
</button>
{error && <p role="alert">{error}</p>}
</div>
);
}One more thing — setError inside a transition is perfectly fine, but be aware that state updates inside startTransition are batched with lower priority. If you need the error to appear urgently (it usually does), call setError outside the transition after the catch, or use a ref to communicate the error imperatively.
For list-level mutations — adding items, removing items, reordering — you'll want to give each optimistic item a stable temporary key (use crypto.randomUUID() or a counter) so React's reconciler doesn't lose DOM nodes during the rollback. Mismatched keys cause flickers that feel much worse than the mutation failure itself.
Look, the bigger mistake I see is adding retry logic inside the optimistic layer. Don't. Keep useOptimistic responsible for the UI state only. Put retry logic in your mutation layer — whether that's a custom hook, a TanStack Query mutation with retry: 3, or an explicit button the user clicks after seeing the error.
Concurrent Mutations: The Hard Part
Things get interesting when users trigger the same mutation multiple times before the first one resolves. A user who taps "Like" three times in 500ms. A form submission that fires twice because of a double-click. These are the cases that break naive optimistic UI implementations.
useOptimistic doesn't debounce or dedupe for you — that's not its job. But because it works with React's transition system, concurrent transitions stack correctly: the last call to addOptimistic wins the render, and each transition's promise is tracked independently.
// Debounce at the handler level — not inside useOptimistic
const handleClick = useDebouncedCallback(() => {
startTransition(async () => {
addOptimistic(nextValue);
await saveValue(nextValue);
});
}, 300);For cases where order matters — like a reorder of list items where each drag-end fires a mutation — you'll want to queue mutations and process them serially. A simple ref-based queue works fine for this. The optimistic layer reflects the intended final state immediately; the queue makes sure the server gets the same sequence.
Worth noting: React DevTools in 19.x shows useOptimistic state separately from regular state in the component tree. If you're debugging a stuck rollback, check there first — you'll see exactly what the optimistic layer holds versus the source-of-truth prop.
Integrating with Design Systems and Styled Components
Optimistic state isn't just a data concern — it needs visual expression. A pending item should look different from a confirmed one. The canonical approach is a pending flag on your optimistic items, rendered as reduced opacity (0.6 is the sweet spot — anything lower starts looking like a broken state), a spinner overlay, or a pulsing animation.
If you're building on a component library that supports style props or variant tokens, add a "pending" variant to your interactive components. This keeps the visual treatment consistent across every mutation in your app — not bolted on per-component with inline styles. You can grab inspiration from Empire UI's glassmorphism components for how layered visual states work well together without becoming visually noisy.
// Clean variant-based approach
<Card variant={item.pending ? 'pending' : 'default'}>
<CardContent>{item.text}</CardContent>
</Card>
// CSS
[data-variant='pending'] {
opacity: 0.65;
pointer-events: none;
/* subtle pulse so users know something is happening */
animation: pulse 1.2s ease-in-out infinite;
}One pattern worth stealing from Figma and Linear: don't animate the optimistic state appearing (that looks janky on fast connections), but DO animate the rollback. A 150ms fade out when an item snaps back signals to users that the system is communicating something — rather than just inexplicably changing state. You can find smooth animation primitives and browse components in the Empire UI library to wire these transitions up quickly.
When Not to Use Optimistic UI
Not every mutation benefits from going optimistic. Before you wire it up, ask yourself: what does failure look like for this action? If the answer is "confusing" or "data loss," you probably want a loading state instead.
Irreversible actions — deleting an account, sending a payment, publishing a live document — should not be optimistic. The 300ms you save is not worth the confusion when a user sees a "deleted" account flash back into existence. Use a loading state with a disabled UI and let the spinner do its job.
In practice, I've found the clearest heuristic is: if you'd show a confirmation dialog for this action, don't make it optimistic. If the user can undo it trivially (un-like, un-follow, edit again), optimistic is almost always the right call.
There's also a network condition question. On a 2G connection, your optimistic state is going to sit there for 4–8 seconds before resolving. Consider whether a timeout fallback makes sense — if the mutation hasn't resolved in, say, 10 seconds, show a warning even if the optimistic state is still displayed. Users on slow connections deserve feedback too.
FAQ
Yes, and it's actually the cleanest setup. Call your server action inside the startTransition callback, let it revalidate the cache, and the optimistic layer replaces itself with fresh server data automatically. No manual rollback needed.
Each startTransition is tracked independently by React. The optimistic state shows the latest addOptimistic call. Both promises run, and the UI reconciles when both settle. For order-sensitive mutations, add a queue at the handler level — not inside useOptimistic.
You'll get a dev warning and broken rollback semantics if you do. The hook relies on React's transition system to know when to discard the optimistic layer. Always wrap the addOptimistic call inside startTransition.
Use the isPending boolean from useTransition for a global indicator (like a spinner in a nav bar), and use the pending flag on your optimistic items for per-item visual treatment. The two work independently and complement each other fine.