Skip to content

Optimistic UI in React 19: useOptimistic After Six Months in Production

Optimistic UI in React 19: useOptimistic After Six Months in Production

Short version for the impatient: useOptimistic is the first React API that made my optimistic update code shorter without making the failure cases worse. I shipped it in three places over the last six months. I’d do it again in two of them. If you want to know why, and where it doesn’t fit, read on.

I had a perfectly fine pattern for optimistic UI before this. A useReducer, a “pending” item array, an onError that reverted, a useEffect to reconcile with the server response. It worked. It was also somewhere around forty lines per feature, and I copied it. Three times. With slightly different bugs each time.

Then I tried useOptimistic on a comment box, deleted twenty of those lines, and stopped pretending I was going to write a generic hook for it.

What useOptimistic actually does

The official version, from the React docs, is that useOptimistic lets you show a different state while an async action is underway. Fine. That’s the polite description.

The version I’d give a junior dev on my team: it’s a useReducer that automatically resets when the surrounding transition completes. The current state is whatever the server most recently returned. The “optimistic” state is what the user sees during a startTransition. When the transition is done, the optimistic state is gone, replaced by whatever your server gave you. You don’t write any rollback code. You don’t have to remember to clean up. The framework does both.

That’s the whole feature. The trick is figuring out where it actually helps.

Before: my old optimistic comment list

Here’s a stripped version of what I had in the codebase before. A list of comments where a user can add one, and we want the new comment to appear right away:

function CommentList({ initialComments, postId }) {
  const [comments, setComments] = useState(initialComments);
  const [pending, setPending] = useState([]);

  async function addComment(text) {
    const tempId = crypto.randomUUID();
    const optimistic = { id: tempId, text, author: "me", status: "pending" };
    setPending((p) => [...p, optimistic]);
    try {
      const saved = await api.createComment(postId, text);
      setComments((c) => [...c, saved]);
    } catch (err) {
      toast.error("Couldn't post that. Try again?");
    } finally {
      setPending((p) => p.filter((c) => c.id !== tempId));
    }
  }

  const all = [...comments, ...pending];
  return <List items={all} onSubmit={addComment} />;
}

It works. I shipped it for two years. The problems weren’t visible until I tried to add a second optimistic feature, deletes, and realized I needed a parallel pendingDeletes array, a way to filter the visible list against both, and a careful order so the optimistic delete didn’t get clobbered by an in-flight create. That’s where it got messy.

After: the useOptimistic version

import { useOptimistic, useState, startTransition } from "react";

function CommentList({ initialComments, postId }) {
  const [comments, setComments] = useState(initialComments);
  const [optimistic, addOptimistic] = useOptimistic(
    comments,
    (state, newComment) => [...state, { ...newComment, status: "pending" }]
  );

  async function addComment(text) {
    startTransition(async () => {
      addOptimistic({ id: crypto.randomUUID(), text, author: "me" });
      try {
        const saved = await api.createComment(postId, text);
        setComments((c) => [...c, saved]);
      } catch (err) {
        toast.error("Couldn't post that. Try again?");
      }
    });
  }

  return <List items={optimistic} onSubmit={addComment} />;
}

A few things to notice. There’s no pending array. There’s no rollback. If the API throws, the startTransition ends, the optimistic state evaporates, and the list goes back to whatever comments holds. The toast tells the user what happened. That’s it.

The thing I didn’t expect: I removed every useEffect I had in this component. The optimistic state derives from the canonical state, so there’s nothing to sync. That alone made the next bug report a lot easier to read.

I cover the matching pattern for forms in my post on useActionState replacing form reducers, if you’re stacking these React 19 hooks together. The two pair up well.

Where it doesn’t fit

Three places I tried it and walked back.

The first one: long-running mutations. If your API takes more than about a second, the optimistic state sits there feeling stale. The user sees their comment appear, then a beat of nothing, then the list reorders when the server replies. I added a subtle “saving” indicator on the optimistic row, which helped, but at that point I was back to tracking a status field. Not worse than before. Just not the win it had been on fast endpoints.

The second one: multi-step transactions. We had a “move card to column” action that fires three requests in sequence. Update the card. Update the source column. Update the destination column. useOptimistic is one reducer over one state. If you want to optimistically reflect “card moved” across three pieces of state, you either lift them into one combined reducer (annoying) or run three useOptimistic hooks (also annoying, and the rollback semantics get fuzzy when one of the three fails). I went back to TanStack Query’s optimistic updates pattern for this one. Their rollback model is more honest about partial failures.

The third one: anything outside a transition. The hook only updates the optimistic state when you call addOptimistic inside startTransition. If you’re trying to use it from an effect that fires on mount, or from a non-transition click handler, you get nothing. The error from React is clear. The misuse is easy to commit anyway.

What I keep around it

A small failure-handling pattern I now reach for every time.

const [optimistic, addOptimistic] = useOptimistic(items, (state, action) => {
  if (action.type === "add") {
    return [...state, { ...action.item, status: "pending" }];
  }
  if (action.type === "remove") {
    return state.filter((i) => i.id !== action.id);
  }
  return state;
});

Two things I do here on top of the docs example. The reducer takes a small action object instead of a raw item, so the same hook handles add and remove without two separate hooks. And every optimistic row carries a status field that the row component uses to dim it slightly. Users notice the dim. They notice nothing about the rollback. That tradeoff matters: people forgive a slow round trip if you tell them it’s happening.

I also keep a try / catch inside startTransition and surface errors with a toast. Don’t skip the toast. The optimistic state will quietly revert and the user will think they hit “post” and the click missed.

The React 19 release post lays out the design rationale if you want the underlying argument: see the React 19 announcement. I had to read it twice before I trusted the automatic cleanup story. Worth it.

What I’d do this week if you’re starting

Pick one feature where you currently fake an optimistic update with setState and a try / catch. A like button, a comment box, a row toggle. Convert that one feature only. Don’t try to do a sweeping refactor across the app. The hook is small enough that you can wrap it in a useTransition and ship it in an afternoon.

After a week of usage in one spot, you’ll have opinions. Mine, after six months: I use it on anything where the request returns in under 800ms, where there’s a single piece of state to touch, and where the error case is “show a toast and forget.” For everything else, the older TanStack Query optimistic update patterns still beat it. That’s not a knock on useOptimistic. It’s a small, focused API that does one job well. Recognizing that scope is most of the win.

If you want more on the React 19 hooks I use day to day, I write up the ones I actually keep across my work at abrarqasim.com. The list is shorter than the headline release notes suggest, and that’s been the better way to learn the new API: small steps, in production, one feature at a time.