useOptimistic in Anger: Why Our Optimistic UI Felt Worse Than No UI
React 19's useOptimistic looks like a free win until you ship it. Here's what broke for us in production — race conditions, ghost rows, and reconciliation traps — and the patterns we now use instead.
We rolled out useOptimistic across a comments feature last quarter and watched our support inbox light up within hours. The UI was technically faster. It also felt broken in a way the old spinner version never did. This is what we learned tearing it back out and rebuilding it properly.
What useOptimistic actually does
The hook is small. You give it a current state and a reducer-like function, and it gives you back an optimistic state plus a dispatcher. Inside a transition (or a server action), the optimistic state takes over. When the transition resolves, React reconciles back to the real state.
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(state: Comment[], newComment: Comment) => [...state, newComment]
);
The docs make it look like a drop-in replacement for whatever ad-hoc optimistic logic you had before. In a toy demo, it is. In production, three things bite you: identity, ordering, and failure.
The mental model that gets you in trouble
Most engineers read useOptimistic and assume the optimistic value persists until the server confirms the specific mutation. It doesn't. It persists until the surrounding transition completes. If your server action returns revalidatePath and the new server data comes back without your optimistic row (because the DB write was eventually consistent, or you read from a replica), the row vanishes for a frame and reappears once the next render lands. Users see a flicker. Sometimes they see a ghost.
War story: the disappearing comment
Our comments list was a server component fetching from Postgres. The form was a client component using useOptimistic plus a server action. The action inserted the row and called revalidatePath('/post/[id]').
What we shipped:
'use client';
export function CommentForm({ comments, postId }: Props) {
const [optimistic, addOptimistic] = useOptimistic(
comments,
(state, draft: Comment) => [...state, draft]
);
async function action(formData: FormData) {
const draft = {
id: crypto.randomUUID(),
body: formData.get('body') as string,
author: 'me',
pending: true,
};
addOptimistic(draft);
await postComment(postId, draft);
}
return (
<>
<CommentList items={optimistic} />
<form action={action}>...</form>
</>
);
}
On slow connections, here's what users saw:
- They submit. Comment appears instantly. Good.
- Server action completes.
revalidatePathfires. - The server component refetches. Postgres replica lag means the new row isn't there yet.
- React receives new
commentsprop. The transition is over. The optimistic row vanishes. - ~400ms later, the replica catches up. A second revalidation lands. Comment reappears.
Two flickers per submit. On a thread with five quick replies, the list looked like it was having a seizure. Worse, if a user submitted, saw their comment disappear, and resubmitted, we got duplicates.
Why the obvious fix doesn't work
The first instinct is to merge the optimistic row into the new server state by ID. But useOptimistic doesn't give you that hook — the reducer only runs when you dispatch, not when the base state updates. You can't say "keep this draft visible until the server-confirmed row with the same client ID arrives."
You have to encode it differently.
The pattern we use now
Three rules, learned the hard way.
1. Generate the ID on the client, return it from the server
The optimistic row and the eventual real row must share an identity. We generate a UUID client-side, pass it to the server action, and the server uses it as the primary key. Now when the real row arrives, we can dedupe.
async function postComment(postId: string, draft: Comment) {
'use server';
await db.insert(comments).values({
id: draft.id, // client-generated
postId,
body: draft.body,
authorId: await getUserId(),
});
revalidateTag(`comments:${postId}`);
}
2. Merge in the reducer, not in the render
Instead of treating useOptimistic as a list-append, treat it as an overlay. The reducer receives the latest base state every time something dispatches, but the dispatched value can carry intent — pending, confirmed, failed.
type Action =
| { type: 'add'; comment: Comment }
| { type: 'fail'; id: string };
const [view, dispatch] = useOptimistic(
comments,
(state: Comment[], action: Action) => {
switch (action.type) {
case 'add': {
if (state.some((c) => c.id === action.comment.id)) return state;
return [...state, { ...action.comment, pending: true }];
}
case 'fail': {
return state.map((c) =>
c.id === action.id ? { ...c, failed: true, pending: false } : c
);
}
}
}
);
The dedupe check on state.some(...) is what saves you when the server data eventually catches up: if the row is already there, the optimistic add is a no-op.
3. Don't rely on revalidatePath alone for the success path
This is the unglamorous part. If you have any read-replica lag or any CDN cache in front of your route, revalidatePath is not a synchronisation primitive — it's a hint. Pair it with a tag-based revalidation and accept that there will be a window where the UI shows the optimistic version. That's fine, as long as the optimistic row carries the same ID as the eventual real one.
For lists where ordering matters (chat, activity feeds), sort by a deterministic field like createdAt that you can set client-side. Otherwise the optimistic row jumps position when it gets reconciled.
When useOptimistic is the wrong tool
We've stopped reaching for it in three cases.
Multi-step mutations. If your action does "upload file → process → write row", the transition completes when the action returns, not when the work finishes. The optimistic state evaporates while a background job is still running. Use a real state machine and poll, or use a websocket.
Mutations that affect siblings. Optimistically liking a post is fine. Optimistically liking a post that updates a leaderboard, a notification count, and a recommendation feed is not. useOptimistic is scoped to one piece of state. Cross-cutting updates need a client cache (TanStack Query, SWR, or a Zustand store you own) where you can write to multiple keys atomically.
Anything financial. We don't do optimistic UI on payments, balance changes, or anything a user might screenshot and complain about. The cost of a wrong intermediate state is higher than the cost of a 600ms spinner. This is a product call, not a technical one.
What about useActionState?
People conflate the two. useActionState (formerly useFormState) tracks the result of a server action — typically for validation errors and success messages. useOptimistic tracks an intermediate UI state during the action. You usually want both: useActionState for the form-level outcome, useOptimistic for the list-level preview.
const [state, formAction, isPending] = useActionState(postComment, null);
const [view, dispatch] = useOptimistic(comments, reducer);
The isPending flag from useActionState is also how you should drive submit-button disabled states. Don't try to derive it from the optimistic state.
Accessibility footnote nobody mentions
Optimistic rows are a screen reader hazard if you don't think about live regions. We render the optimistic comment with aria-busy="true" and a visually subtle pending style. When it reconciles, aria-busy flips to false. Without this, NVDA and VoiceOver users get announcements for content that may not exist yet. We've also seen Lighthouse flag the layout shift when an optimistic row gets reordered — fix it by reserving min-height on the list item from the start.
Where we'd start
If you're adopting useOptimistic on an existing feature, do this first:
- Audit whether your server action's success can be observed synchronously. If there's any async write path (queues, replicas, CDN), assume reconciliation will be eventually consistent and design the reducer to dedupe by ID.
- Make IDs client-generated for any entity you want to optimistically render.
- Add a
pendingflag to the optimistic row and style it. Users tolerate uncertainty if you signal it; they don't tolerate UI that lies. - Test on a throttled connection with replica lag simulated. If you can't reproduce the disappearing-row bug locally, you'll ship it.
We still use useOptimistic — it's the right primitive for the right job. We just stopped pretending it removes the need to think about consistency. If you want help untangling an optimistic UI that's misbehaving in production, that's the sort of work our team does on web engineering engagements.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Route Handlers vs Server Actions for Public APIs: Pick the Right Door
Server actions look tempting for every mutation, but they're the wrong front door for public APIs. Here's the rule we use in production, and the failure modes that taught us the difference.

Partial Prerendering in Production: What Actually Ships and What Doesn't
Partial Prerendering promised the best of static and dynamic. After shipping it across three production apps, here's what works, what breaks, and where the seams still show.
Server Actions at Scale: Why We Stopped Using Them for Everything
Server Actions in Next.js feel like magic until your app grows up. Here's where they shine, where they hurt, and the rules we now apply before reaching for them in production.
