React 19's useOptimistic in Anger: Patterns That Survive Network Failures
useOptimistic feels magical in demos and brittle in production. Here's how we wire it up so optimistic UI doesn't lie to users when the network goes sideways.

Optimistic UI is one of those ideas that demos beautifully and falls apart the first time someone toggles airplane mode mid-tap. React 19's useOptimistic makes the happy path trivial, but the failure paths are where the real work lives. This is what we've learned wiring it into real Next.js App Router apps where the network is hostile and users are impatient.
What useOptimistic actually gives you
The hook is small on purpose. You hand it a base state and a reducer, and it returns a derived state plus a dispatch that only lives for the duration of a transition.
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state: Todo[], pending: Todo) => [...state, { ...pending, pending: true }]
);
That's it. There's no built-in queue, no rollback semantics, no retry. When the surrounding transition resolves (success or failure), React simply drops the optimistic layer and re-renders from the canonical state. The mental model people get wrong: useOptimistic does not commit anything. It paints a temporary picture on top of whatever the source of truth says.
That distinction is the whole article. Once you internalize it, the failure patterns get easier.
Where it sits in the App Router
In a Next.js 15+ app, the typical setup is a server component that fetches data, a client component that renders it, and a server action that mutates it. useOptimistic lives in the client component and brackets the server action call inside a startTransition or a <form action> submission. The server action revalidates a tag or path, the RSC payload comes back, and the optimistic layer evaporates.
That flow is clean when the action succeeds in 200ms. It is not clean when the action takes 9 seconds, fails, or the user fires three more mutations while the first is in flight.
The four failure modes you'll actually hit
We've debugged each of these in real client work. They're worth naming.
- Silent rollback. The action throws, the optimistic state vanishes, and the user sees their change disappear with no explanation.
- Stale optimism. The user fires action B before action A returns. React reconciles A, the optimistic layer drops, and B's optimism gets clobbered until B also resolves.
- Revalidation gap. The server action succeeds, but
revalidateTagruns against a cache that hasn't propagated yet. The optimistic layer drops before fresh data arrives, causing a flash of the old state. - Form resets too early. With
<form action>, the form resets on submission, not on completion. If your optimistic state derives anything from form values, it can desync.
Pattern 1: Make rollback visible, not silent
The default behavior — optimistic state disappears on error — is the worst UX outcome. Users assume their action worked. We always wrap server actions so failures produce a structured result, then push the failure into the optimistic reducer itself.
type OptimisticTodo = Todo & {
status: 'pending' | 'failed' | 'confirmed';
tempId?: string;
};
type Action =
| { type: 'add'; todo: Todo; tempId: string }
| { type: 'fail'; tempId: string; error: string };
const [optimisticTodos, dispatch] = useOptimistic(
todos as OptimisticTodo[],
(state, action: Action) => {
switch (action.type) {
case 'add':
return [...state, { ...action.todo, status: 'pending', tempId: action.tempId }];
case 'fail':
return state.map((t) =>
t.tempId === action.tempId ? { ...t, status: 'failed' } : t
);
}
}
);
The trick: on failure, don't let the transition end. Keep it open with another startTransition that dispatches a fail action, so the optimistic layer persists with a visible error state and a retry button. The user sees what they did, sees it failed, and can act.
Pattern 2: Stable temp IDs, server-reconciled
When the user creates an entity, the server assigns the real ID. Until then, you need something stable to key on. crypto.randomUUID() in the client, returned by the server action in its result, is the simplest contract.
async function handleSubmit(formData: FormData) {
const tempId = crypto.randomUUID();
const draft = { title: formData.get('title') as string };
startTransition(async () => {
dispatch({ type: 'add', todo: draft as Todo, tempId });
const result = await createTodo(draft, tempId);
if (!result.ok) {
dispatch({ type: 'fail', tempId, error: result.error });
}
});
}
The server action accepts the tempId and echoes it back. If you're doing list animations, key your <li> elements by tempId ?? id so React doesn't unmount and remount the row when the real ID arrives. That single change kills a whole class of flicker bugs.
Pattern 3: Treat concurrent mutations as a queue
useOptimistic itself doesn't queue. If two transitions overlap, the second one's optimistic state is computed against the current base state, not on top of the first transition's optimism. Most of the time this is fine — the reducer composes additions correctly. But for ordered edits (rename, then rename again), you'll see weird intermediate states.
Our rule: if mutations are non-commutative, serialize them in userland. A tiny queue around startTransition is enough.
const queueRef = useRef<Promise<unknown>>(Promise.resolve());
function enqueue(work: () => Promise<void>) {
queueRef.current = queueRef.current.then(work).catch(() => {});
return queueRef.current;
}
You lose a little parallelism. You gain predictable ordering. For things like reordering a Kanban column or editing a doc title, that trade is correct.
When not to queue
For independent additions (chat messages, likes, cart items), let them fly in parallel. Queueing those just adds latency. The heuristic: if two operations would conflict on a CRDT, queue them; otherwise don't.
Pattern 4: Close the revalidation gap
The race between revalidateTag and the client receiving fresh RSC data is real, especially on slow connections or when ISR is involved. Symptoms: the optimistic row disappears for ~200ms before the confirmed row appears.
Two mitigations we use:
- Return the canonical entity from the server action and merge it into local state via a
useStatemirror, rather than relying purely on revalidation. This is slightly more code but bulletproof. - Hold the optimistic layer until the RSC round-trip completes. Since the transition wraps the whole call, keep awaiting until the server action's revalidation has actually been reflected. In practice this means awaiting the action and then a microtask, or awaiting an explicit
router.refresh()if you're not using tag revalidation.
Neither is free. The first introduces a state synchronization point. The second keeps the transition open longer, which can feel sluggish. Pick based on whether your data shape supports easy merging.
Accessibility: announce the truth, not the lie
Optimistic UI is by definition not the truth yet. Screen readers will announce optimistic content as if it were confirmed, which is misleading. We pair the optimistic render with an aria-live="polite" region that only announces confirmed changes — typically after the action resolves successfully. On failure, the same region announces the error.
<div role="status" aria-live="polite" className="sr-only">
{lastConfirmed && `Added ${lastConfirmed.title}`}
{lastError && `Failed to add: ${lastError}`}
</div>
This is the kind of detail that never shows up in a tutorial and always shows up in an accessibility audit.
Testing the failure paths
Most teams test the happy path and ship. The interesting tests are:
- Force the server action to reject and assert the failed-state UI renders with a retry affordance.
- Fire three rapid mutations and assert the final state matches the server's view, not whichever optimistic state happened to win.
- Throttle the network to Slow 3G in Playwright and verify there's no flash of unstyled or stale content during the revalidation gap.
If those three pass, you've already shipped better optimistic UI than 90% of apps using this hook.
Where we'd start
If you're adding useOptimistic to an existing Next.js app this quarter, do it in this order: pick one mutation (creation is easiest), add a status field to your optimistic type, wire a visible failed state with retry, then add the temp-ID contract end-to-end. Don't try to retrofit the whole app at once — the patterns above compose, but only once your data model has room for them. If you want a second pair of eyes on the migration, our web development team does this kind of work regularly, and the same playbook applies whether you're on React 19 stable or still on a canary build.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Cache Invalidation in Next.js App Router: A Field Guide
revalidateTag, revalidatePath, and the Data Cache look simple until you ship them. Here's how we reason about Next.js caching layers, what bites teams in production, and the mental model we wish we'd had on day one.

Streaming Suspense Boundaries: Where to Put Them So TTFB Actually Drops
Suspense in the Next.js App Router is a TTFB lever, not a loading spinner. Here's how we decide where the boundaries go on real product pages — and where they backfire.

Partial Prerendering in Next.js: When the Hype Meets Real Apps
Partial Prerendering promises the speed of static with the freshness of dynamic. Here's what actually happens when you ship it past a marketing site, and where it quietly bites.
