Short version for the impatient: useOptimistic in React 19 lets you show a pretend “done” UI the instant the user clicks, then quietly reconcile with what the server actually says. It’s not magic, it’s not a replacement for proper state management, and I still forget it exists about once a week. If you want the long version and the mistakes I made so you don’t have to, read on.
I was building a little comment thread feature last Tuesday and caught myself writing the same awkward setPending(true) dance I’ve written since 2018. Halfway through, I remembered React 19 shipped with a hook designed exactly for this. I had to go re-read the docs because I kept confusing it with useTransition. So this post is the cheat sheet I wish I’d written for myself.
What useOptimistic actually does
The hook gives you a second, temporary copy of your state that you can mutate instantly while an async update is in flight. When the real update resolves (or fails), the optimistic copy gets thrown away and React snaps back to whatever the real state is.
The API is small:
const [optimisticState, addOptimistic] = useOptimistic(
actualState,
(currentState, optimisticValue) => nextState
);
That second argument is a reducer. It’s called with the current optimistic state and whatever value you pass to addOptimistic, and you return the new optimistic state. Same shape as useReducer, just scoped to the optimistic layer.
The bit that tripped me up the first time: addOptimistic only works inside a transition. If you call it from a plain event handler with no async work around it, React throws. In practice that means you call it right before (or inside) a Server Action or startTransition block. The React docs for useOptimistic spell this out, but I missed it on first read.
The before/after that sold me

Here’s the old way I was writing an optimistic like button. It works, but look at how much ceremony there is for “show the heart as red immediately”:
// React 18 way
function LikeButton({ postId, initialLiked, initialCount }) {
const [liked, setLiked] = useState(initialLiked);
const [count, setCount] = useState(initialCount);
const [pending, setPending] = useState(false);
async function handleClick() {
const prevLiked = liked;
const prevCount = count;
setLiked(!liked);
setCount(liked ? count - 1 : count + 1);
setPending(true);
try {
await fetch(`/api/like/${postId}`, { method: 'POST' });
} catch (e) {
setLiked(prevLiked);
setCount(prevCount);
} finally {
setPending(false);
}
}
return <button onClick={handleClick} disabled={pending}>{liked ? '♥' : '♡'} {count}</button>;
}
Three useState calls, manual rollback, and I have to remember to snapshot the previous values before I mutate. I got this wrong for two weeks on a side project because I forgot the rollback path on a network error and users saw phantom likes.
Here’s the same thing with useOptimistic and a Server Action:
// React 19 way
import { useOptimistic } from 'react';
import { toggleLike } from './actions';
function LikeButton({ postId, liked, count }) {
const [optimistic, addOptimistic] = useOptimistic(
{ liked, count },
(state) => ({ liked: !state.liked, count: state.liked ? state.count - 1 : state.count + 1 })
);
async function handleClick() {
addOptimistic(null);
await toggleLike(postId);
}
return (
<form action={handleClick}>
<button>{optimistic.liked ? '♥' : '♡'} {optimistic.count}</button>
</form>
);
}
No rollback code. If toggleLike throws, React discards the optimistic value and renders whatever the real liked/count props are (which come from the server on the next render). The rollback is the absence of a commit.
That’s the trick worth internalizing: optimistic state isn’t “state you update”. It’s “a derived view of real state plus a pending action”. If the action never commits, the view never changes.
The task list example, with actual subtleties
The canonical example in every tutorial is a task list. I’ll do it too, but I’ll show the bit the tutorials skip: what happens when the user clicks three times in a row.
function TodoList({ todos, addTodoAction }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(current, newTodo) => [...current, { ...newTodo, pending: true }]
);
async function formAction(formData) {
const text = formData.get('text');
addOptimisticTodo({ id: crypto.randomUUID(), text });
await addTodoAction(text);
}
return (
<>
<form action={formAction}>
<input name="text" />
<button>Add</button>
</form>
<ul>
{optimisticTodos.map((t) => (
<li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}>{t.text}</li>
))}
</ul>
</>
);
}
If the user smashes the Add button three times quickly, you get three concurrent Server Action calls. React queues the optimistic updates: each one is applied on top of the current optimistic state, in call order. When the first server response comes back, React re-runs the reducer from the new todos base with the remaining in-flight updates layered on top. You don’t have to manage this. It’s genuinely one of the nicer things about the design.
What you do have to manage is keys. If you generate IDs on the client (crypto.randomUUID()), the server’s response will come back with a different ID, and React will unmount and remount the <li>. That’s a wasted render and it’ll kill any CSS transitions you had on the row. Either have the server accept client-generated IDs, or use a separate client-only key for the pending row.
Where it doesn’t fit
I keep seeing people reach for useOptimistic when they should be using local state or useTransition. A few honest warnings:
- If your mutation has no server round-trip, you don’t need this. Just use
useState. Optimistic UI is specifically the pattern for “show the result before the server confirms”. - If the mutation result isn’t predictable, skip it. A form that returns a freshly-calculated discount code can’t be optimistically rendered because you don’t know the answer.
- If you need to roll back to a specific intermediate state, this isn’t the tool. The rollback is always “drop the optimistic layer and show the real state”. Anything more complex needs a reducer.
- If you’re not using Server Actions or transitions, you’re fighting the API. It’ll throw. The Next.js Server Actions docs are the easiest way to wire this up if you’re on the App Router.
I wrote more about how the new compiler changes what code you even need to write around these hooks in my post on the React Compiler — worth a read if you haven’t looked at it yet.
The bug I hit in production
Because this is a real post and not a marketing piece, here’s what actually went wrong when I first shipped this. I had an optimistic “delete” action on a list of messages. The reducer filtered out the deleted message. Worked great locally. In production, the server action was wrapped in an auth check that sometimes took 400ms on cold starts. During that 400ms, if another message arrived via websocket and triggered a re-render with new messages props, the optimistic layer was re-applied from the new base — correctly filtering the deleted one. Also correctly showing the new message. So far, so good.
The bug: my reducer was doing current.filter(m => m.id !== deletedId). If the deleted message was the new one that had just arrived, the filter removed it. If it wasn’t, the filter was a no-op, and the UI showed the “deleted” message for 400ms until the server caught up. I fixed it by making the reducer aware that a delete had been requested and rendering those rows with a “disappearing” style regardless of whether the server had confirmed yet.
The lesson: your optimistic reducer runs every time the real state changes, not just once. Treat it like a pure projection, not a one-shot side effect. Dan Abramov’s original React 19 announcement post is worth re-reading once you’ve hit a bug like this — the concurrent-update section makes more sense after you’ve been burned.
What to try this week
Pick one form in your app that currently has a loading spinner and a disabled submit button. Replace that whole dance with useOptimistic and a Server Action. Time yourself. My rough rule: if the conversion takes more than twenty minutes, you’re fighting the data flow and should probably refactor that component first. If it takes five, you’ve found a place the hook was designed for, and you should go look for more of those.
If you want to see how I approach this stuff in larger projects, I keep notes and examples in my work portfolio — most of the recent builds use Server Actions as the default mutation path, and useOptimistic in maybe a third of forms. Not everywhere. The trick is knowing when it earns its keep.