The useOptimistic Trap: When Optimistic UI Lies to Your Users
React 19's useOptimistic looks like a free win until a server action fails halfway through a batch and your UI cheerfully shows state that never existed. Here's how we got burned and how we now use it.
useOptimistic is one of those React 19 primitives that demos beautifully and ships poorly. The five-line examples in the docs feel like magic; the first production incident where a user swears they saved something they didn't feels like betrayal. We've now shipped it in four separate apps, and the pattern we started with is not the pattern we ended with.
This is the write-up we wish we'd had before the first incident.
The demo that fooled us
Here's the version of useOptimistic that lives in tutorials. A todo list, a server action, a hook, done.
'use client';
import { useOptimistic, useTransition } from 'react';
import { addTodo } from './actions';
type Todo = { id: string; text: string; pending?: boolean };
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(state, newTodo: Todo) => [...state, { ...newTodo, pending: true }]
);
const [, startTransition] = useTransition();
async function onSubmit(formData: FormData) {
const text = formData.get('text') as string;
startTransition(async () => {
addOptimistic({ id: crypto.randomUUID(), text });
await addTodo(text);
});
}
return (
<form action={onSubmit}>
{/* input + list */}
</form>
);
}
This works. It also lies. If addTodo throws, React silently reverts the optimistic state on the next render — and the user sees their todo appear, then vanish, with no explanation. On a slow connection they may have already scrolled away.
That's the trap. useOptimistic doesn't give you failure UI for free. It gives you a temporary state overlay that disappears when the transition resolves, whether the action succeeded or not.
What actually happens on failure
The mental model most engineers arrive with is: optimistic update → server confirms → real update replaces optimistic one. That's roughly correct on the happy path. On the sad path:
addOptimisticpushes the temporary state into the reducer.- The server action rejects (thrown error, redirect, timeout).
- The transition ends.
- React re-reads the base state (
todos) — which was never mutated — and the optimistic entry disappears. - No error is thrown to your component tree unless you caught it inside
startTransition.
If you didn't wrap the server action call in a try/catch, the error becomes an unhandled promise rejection in a transition, which — depending on your React version and error boundary setup — may or may not surface at all. We had a bug in staging for two weeks where 4xx responses from a rate-limited endpoint were completely invisible to users.
The batch problem
It gets worse with multiple optimistic updates. Say a user selects five rows in a table and clicks "Archive". You loop, calling addOptimistic for each, then fire five server actions. Three succeed, two fail. What does the user see?
They see all five items archived optimistically, then two of them reappear when the base state re-syncs — but only after the slowest failed action resolves. In the meantime the UI looks fine. Users move on. They close the tab. They tell support the archive worked and are surprised the next morning when two rows are back.
The pattern we use now
Three changes fixed most of our incidents.
1. Return errors from server actions, don't throw them
Throwing inside a server action gives you a 500 and an unhandled rejection. Returning a discriminated union gives you something you can render.
// app/todos/actions.ts
'use server';
import { z } from 'zod';
type ActionResult<T> =
| { ok: true; data: T }
| { ok: false; error: string; code: 'validation' | 'auth' | 'server' };
const schema = z.object({ text: z.string().min(1).max(200) });
export async function addTodo(
text: string
): Promise<ActionResult<{ id: string }>> {
const parsed = schema.safeParse({ text });
if (!parsed.success) {
return { ok: false, error: 'Todo text is required', code: 'validation' };
}
try {
const id = await db.todos.insert({ text: parsed.data.text });
return { ok: true, data: { id } };
} catch (e) {
return { ok: false, error: 'Could not save', code: 'server' };
}
}
This alone catches maybe 70% of the confusion. You now have a value you can inspect after the transition.
2. Track failed optimistic entries explicitly
Instead of letting failed items silently disappear, keep them in a separate state slice with an error flag so the user can see what happened and retry.
'use client';
import { useOptimistic, useTransition, useState } from 'react';
import { addTodo } from './actions';
type Todo = { id: string; text: string };
type OptimisticTodo = Todo & {
status: 'pending' | 'error';
errorMessage?: string;
};
type Action =
| { type: 'add'; todo: OptimisticTodo }
| { type: 'fail'; id: string; message: string };
function reducer(
state: OptimisticTodo[],
action: Action
): OptimisticTodo[] {
switch (action.type) {
case 'add':
return [...state, action.todo];
case 'fail':
return state.map((t) =>
t.id === action.id
? { ...t, status: 'error', errorMessage: action.message }
: t
);
}
}
export function TodoList({ todos }: { todos: Todo[] }) {
const base: OptimisticTodo[] = todos.map((t) => ({
...t,
status: 'pending',
}));
const [optimistic, dispatch] = useOptimistic(base, reducer);
const [failed, setFailed] = useState<OptimisticTodo[]>([]);
const [, startTransition] = useTransition();
async function onSubmit(formData: FormData) {
const text = formData.get('text') as string;
const tempId = crypto.randomUUID();
startTransition(async () => {
dispatch({
type: 'add',
todo: { id: tempId, text, status: 'pending' },
});
const result = await addTodo(text);
if (!result.ok) {
// Persist the failure outside the optimistic reducer
setFailed((f) => [
...f,
{ id: tempId, text, status: 'error', errorMessage: result.error },
]);
}
});
}
return (
<>
{/* render optimistic + failed together, with retry UI */}
</>
);
}
The key move: failures graduate out of the optimistic reducer and into regular useState, so they survive the transition ending. You can then render a small error row with a "Retry" button that re-fires the action.
3. Never fan out optimistic updates without a summary
For batch operations, we now show an aggregate status ("Archiving 5 items…") and only mark the batch complete when we know each item's fate. If any failed, we surface a summary toast: "2 of 5 items could not be archived." No silent reverts.
Where useOptimistic still earns its keep
Despite the sharp edges, we keep reaching for it. The scenarios where it clearly wins:
- Single-item, low-risk mutations. Likes, bookmarks, toggle switches. The failure cost is low and users retry naturally.
- Actions with fast, predictable latency. If your P95 server action time is under 200ms, users rarely notice the optimistic layer at all — it just papers over network jitter.
- Inputs where local echo matters more than truth. Chat message composition, comment drafts, form field autosave indicators.
We avoid it for anything financial, destructive, or that changes URL state. If a mutation causes navigation or a route-level revalidation, useOptimistic fights against the router's own state model and you end up with two sources of truth.
The revalidation gotcha
One last footgun: calling revalidatePath or revalidateTag inside a server action that's driving optimistic UI will trigger a fresh server render. If that render arrives before your optimistic transition ends, you get a flicker where the optimistic entry disappears, then the real one appears. On slow devices this reads as a UI glitch.
The fix is either to skip the revalidation and mutate the client cache manually, or to accept a brief loading state and drop the optimistic layer for that specific action. We've done both; the right answer depends on how much of the surrounding page depends on that data being fresh.
Where we'd start
If you're introducing useOptimistic to an existing app: audit every server action it touches first, convert them to return result objects rather than throw, and add explicit failure state before you add the optimistic hook. Ship a small, single-item mutation first — a favourite toggle or a rename — and watch your error logs for a week before rolling it out to anything batchy.
And if you're building a design system around this, expose a <OptimisticRow status="pending" | "error" onRetry={...} /> primitive so product engineers don't each solve the failure UI from scratch. That single component saved us more incident reports than any framework upgrade.
If you'd rather have someone else make these calls in your codebase, our web team does this for a living.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Middleware on the Edge: The Auth Pattern That Kept Biting Us in Production
Middleware feels like the obvious place to gate auth in the App Router. Then the cold starts, cookie races, and stale sessions show up. Here's what we changed after shipping this to real traffic.
Route Handlers vs Server Actions for Mutations: When Each One Actually Wins
Server Actions look like the obvious choice for every mutation in the App Router. They aren't. Here's how we decide between actions and route handlers on real projects, with the failure modes we've hit.
Cache Tags in the App Router: The Invalidation Story That Finally Works
Cache tags in Next.js finally give us a real invalidation model, but they punish sloppy tagging with stale data and surprise revalidations. Here's how we tag, invalidate, and debug them in production.
