The `useOptimistic` Trap: When Optimistic UI Lies to Your Users
React 19's useOptimistic hook is easy to reach for and hard to get right. Here's the pattern we settled on after shipping — and rolling back — three variants in production.

useOptimistic looks like a gift. Two lines of code and your UI feels instant. Then a user double-clicks, the network flakes, and the list on screen shows two items that don't exist while the one they actually created is missing. We've shipped this hook three different ways across client projects in the last year, and only the third one survived contact with real traffic.
This is the write-up we wish we'd had before we started.
What useOptimistic actually does
The mental model most engineers arrive with is wrong, and that's the root of the trouble. useOptimistic does not persist an optimistic value. It doesn't own state. It's a derived view that lives only for the duration of a transition.
Here's the signature:
const [optimisticState, addOptimistic] = useOptimistic<State, Action>(
passthroughState,
(currentState, action) => nextState
);
The key detail: optimisticState equals passthroughState except while a transition is pending. When the transition resolves — successfully or not — React throws away the optimistic layer and shows you whatever passthroughState is at that moment.
That's the whole contract. Every bug we've hit stems from ignoring one of those words.
The passthrough must be authoritative
If passthroughState comes from a Server Component prop, it only updates when the route re-renders (a revalidatePath, a router.refresh(), a navigation). If your server action mutates the database but doesn't trigger revalidation, the optimistic value vanishes and the UI snaps back to stale data. Users see their change appear, disappear, then reappear a second later when revalidation lands. It looks like a bug because it is one.
War story: the double-submit that duplicated everything
This is the one that hurt. A B2B dashboard, users adding line items to an invoice. The pattern was textbook:
'use client';
export function LineItems({ items }: { items: Item[] }) {
const [optimisticItems, addOptimisticItem] = useOptimistic(
items,
(state, newItem: Item) => [...state, newItem]
);
async function action(formData: FormData) {
const draft: Item = {
id: crypto.randomUUID(),
label: formData.get('label') as string,
};
addOptimisticItem(draft);
await createItem(draft);
}
return (
<form action={action}>
{optimisticItems.map((i) => <Row key={i.id} item={i} />)}
<input name="label" />
</form>
);
}
Looks fine. Ships fine. Then a sales engineer with a flaky hotel Wi-Fi double-submits, and both requests succeed on the server because we forgot idempotency. Two rows on the server, two optimistic rows during the transitions, and after revalidation the list shows four items briefly as the optimistic layer overlaps with the fresh server data.
The fix was two things, and you need both:
- Reconcile by ID in the reducer, not by append.
- Send the client-generated ID to the server so the row that comes back has the same key.
const [optimisticItems, addOptimisticItem] = useOptimistic(
items,
(state, incoming: Item) => {
const existing = state.find((i) => i.id === incoming.id);
if (existing) return state; // server value already present
return [...state, { ...incoming, pending: true }];
}
);
The pending: true flag is what lets you dim the row or show a spinner without a separate useFormStatus dance for each row.
Why not just useTransition and set state?
We tried that first. It works, but you end up hand-rolling the rollback logic on error, and you own the reconciliation forever. useOptimistic gives you automatic rollback for free — as long as you respect the passthrough contract.
Error rollback is the feature, not a bug
A lot of the complaints we see online — "my optimistic UI disappears when the action fails!" — are people fighting the intended behavior. If your action throws, the optimistic layer is discarded. That's correct. The user's change didn't happen, so showing it is a lie.
What you actually want is:
- The optimistic row disappears.
- A toast or inline error appears explaining why.
- The form value is preserved so the user can retry without retyping.
With useActionState alongside useOptimistic, this composes cleanly:
'use client';
type ActionResult = { ok: true } | { ok: false; error: string; draft: Item };
export function AddItemForm({ items }: { items: Item[] }) {
const [optimisticItems, addOptimisticItem] = useOptimistic(
items,
(state, draft: Item) => [...state, { ...draft, pending: true }]
);
const [result, submit, isPending] = useActionState<ActionResult, FormData>(
async (_prev, formData) => {
const draft: Item = {
id: crypto.randomUUID(),
label: String(formData.get('label') ?? '').trim(),
};
if (!draft.label) return { ok: false, error: 'Label required', draft };
addOptimisticItem(draft);
try {
await createItem(draft);
return { ok: true };
} catch (e) {
return { ok: false, error: 'Save failed', draft };
}
},
{ ok: true }
);
return (
<form action={submit}>
<ul>{optimisticItems.map((i) => <Row key={i.id} item={i} />)}</ul>
<input
name="label"
defaultValue={!result.ok ? result.draft.label : ''}
aria-invalid={!result.ok}
/>
{!result.ok && <p role="alert">{result.error}</p>}
<button disabled={isPending}>Add</button>
</form>
);
}
Note the defaultValue trick: when the action returns an error result, we re-hydrate the input from the failed draft. This is a small detail that separates production-grade forms from demoware.
The four rules we now follow
After enough incidents, we wrote these down and put them in the design system README.
1. Always reconcile by stable ID
Never [...state, newItem] blindly. Always check whether the item is already present. The client generates the ID (UUIDv7 is our default because it sorts lexicographically) and passes it to the server. The server uses it as the primary key or upsert target.
2. Trigger revalidation from the server action
Every mutation ends with revalidatePath or revalidateTag. If you skip this, the optimistic UI works until it doesn't, and "doesn't" happens when the user navigates away and back.
3. Never call addOptimistic outside a transition
React will warn you, but the warning is easy to miss in dev. If you're not inside a form action or a startTransition callback, the optimistic update is silently dropped. We lint for this now.
4. Show pending state on the optimistic row, not the whole list
The whole point of optimistic UI is that the rest of the app stays responsive. A global spinner defeats it. Attach pending: true in the reducer and style that row differently — lower opacity, no context menu, no delete button. Accessibility-wise, mark it with aria-busy="true" so screen readers announce the in-flight state.
What breaks at the edges
A few things we hit that aren't obvious from the docs:
- Streaming responses: if your Server Component streams and the initial
itemsprop lands after the user has already triggered an optimistic update, the reducer runs against an empty array. Guard against this by rendering the form only after the initial data has hydrated, or by making the reducer tolerant of missing baseline state. - Route caching: on Vercel's edge runtime, aggressive route caching can serve a stale
passthroughStateeven afterrevalidatePath. We had to addcache-tagheaders on the fetch and revalidate by tag instead. If you're working through a caching audit, our team has written more about that pattern elsewhere on the blog. - Form resets:
<form action={...}>resets uncontrolled inputs on success. If you want to preserve the input (e.g., "add another"), you need to control it yourself or use akeyreset trick. This has nothing to do withuseOptimisticdirectly, but it's the second bug report you'll get after shipping.
When not to use it
Optimistic UI is a lie you tell users to make the app feel faster. Sometimes the lie isn't worth it:
- Money movements: never show a transfer as complete before the server confirms. Users will screenshot it.
- Multi-user documents: if two people can edit the same object, an optimistic overwrite that later reconciles against someone else's change is worse than a small spinner.
- Long-running server work: if the action takes more than ~800ms on the p95, you're not optimizing perceived performance, you're hiding a real problem. Fix the backend.
For everything else — adding a tag, toggling a favorite, reordering a list, posting a comment — useOptimistic is the right tool once you respect its contract.
Where we'd start
If you're adding optimistic UI to an existing Next.js App Router project this week: pick one high-frequency, low-stakes interaction (favoriting, tag toggles, list reordering) and ship it with the reducer-by-ID pattern above. Wire up useActionState for error rollback from day one, even if your first version can't fail. Then measure — INP is the metric that moves, not LCP — and only expand the pattern once you've seen it survive a week of production traffic. The hook is worth it. Just don't let it lie to your users.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Partial Prerendering in Production: What Breaks When Your Shell Isn't Really Static
Partial Prerendering promises a static shell with dynamic holes. In production, the line between the two is blurrier than the docs suggest. Here's what tripped us up and how we fixed it.

Font Subsetting in Next.js: How We Cut CLS to Near Zero on a Content-Heavy Site
A war story about chasing a stubborn 0.18 CLS score on a publishing site, and the font subsetting and fallback metrics work that finally got us to 0.02.
Server Actions Under Load: What We Learned Rate-Limiting Them at the Edge
Server Actions look like plain function calls, but every one is a POST to your origin. Here's what happened when ours got hammered — and the edge rate-limiting pattern we now ship by default.
