All articles
Web DevelopmentJune 28, 2026 6 min read

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:

  1. They submit. Comment appears instantly. Good.
  2. Server action completes. revalidatePath fires.
  3. The server component refetches. Postgres replica lag means the new row isn't there yet.
  4. React receives new comments prop. The transition is over. The optimistic row vanishes.
  5. ~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:

  1. 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.
  2. Make IDs client-generated for any entity you want to optimistically render.
  3. Add a pending flag to the optimistic row and style it. Users tolerate uncertainty if you signal it; they don't tolerate UI that lies.
  4. 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.

#React 19#Next.js#Server Actions#UX#Performance

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project