Okay, quick story. About four months ago I shipped a comments feature for a client dashboard. User clicks Post, we send it to the server, wait for the round-trip, then render the comment. Standard stuff. And every single test user asked me the same thing during the walkthrough: “Is it broken? Why is it lagging?”
It wasn’t lagging. The round-trip was around 180ms. But 180ms of nothing happening felt slower than 800ms of something happening. So I did what everyone does. I built the optimistic update by hand. Rendered the pending comment with a fake ID, stashed the original state in a ref, wrote rollback logic for the failure case, prayed the state stayed in sync when a user posted three comments in a row.
That code lived for two weeks. Then I ripped it out and used useOptimistic. If you’re still hand-rolling optimistic updates in a React 19 app, this is the post I wish someone had shoved at me back in March.
What useOptimistic actually does
useOptimistic shipped as part of the React 19 release and lets you show a temporary state during an async action, then automatically snap back to the real state when that action resolves. That’s it. No state machine. No rollback code. The docs are pretty terse, so read them first if you want the primary source.
The signature reads like useState with an extra reducer:
const [optimistic, addOptimistic] = useOptimistic(
state,
(currentOptimistic, action) => nextOptimistic
);
You pass the real state and a reducer that describes the temporary update. React shows optimistic while an action is pending, and swaps back to state when the transition finishes.
The catch: it only works inside a transition. That means a form action, a call from useActionState, or an explicit startTransition(() => ...). Call addOptimistic outside a transition and React logs a warning and does nothing. This tripped me up for a full afternoon.
The before code I’d rather forget
Here is roughly what my hand-rolled comment poster looked like. Cleaner than the original, still ugly.
function CommentBox({ postId }) {
const [comments, setComments] = useState([]);
const [pending, setPending] = useState(null);
async function submit(text) {
const tempId = crypto.randomUUID();
const snapshot = comments;
setPending({ id: tempId, text, status: 'sending' });
setComments([...comments, { id: tempId, text }]);
try {
const saved = await postComment(postId, text);
setComments((c) =>
c.map((x) => (x.id === tempId ? saved : x))
);
} catch (err) {
setComments(snapshot);
toast.error('Comment failed');
} finally {
setPending(null);
}
}
// ...
}
Every optimistic feature I built looked like this. A snapshot, a temp record, a swap on success, a revert on failure, a pending flag for the spinner. Multiply by five components in the same app and I had roughly 150 lines of “not real business logic” I was maintaining. The reverts were the worst part. If the user acted again before the first request resolved, the snapshot was stale and the rollback would erase the second comment too.
The after with useOptimistic
Here is the same feature with useOptimistic and a Next.js server action. I dropped the snapshot logic, the pending flag, and the manual swap.
'use client';
import { useOptimistic } from 'react';
import { postComment } from './actions';
export function CommentBox({ postId, comments }) {
const [optimistic, addOptimistic] = useOptimistic(
comments,
(state, newComment) => [
...state,
{ ...newComment, pending: true },
]
);
async function action(formData) {
const text = formData.get('text');
addOptimistic({ id: crypto.randomUUID(), text });
await postComment(postId, text);
}
return (
<form action={action}>
<ul>
{optimistic.map((c) => (
<li key={c.id} className={c.pending ? 'opacity-50' : ''}>
{c.text}
</li>
))}
</ul>
<input name="text" />
<button>Post</button>
</form>
);
}
Notice what is gone: no useState for the list, no snapshot, no rollback branch, no pending flag as separate state. The pending: true marker lives inside the optimistic entry, so I can style pending items differently without a parallel data structure.
The rollback happens for free. If postComment throws, the transition fails and React drops the optimistic entry. If the user posts three comments back to back, each queues into its own transition and resolves independently. No stale-snapshot bug because there’s no snapshot to be stale.
The one code-review complaint I got: no visible loading feedback on outright failure. Fixed with a try/catch inside the action that fires a toast on error. React still handles the state part.
Where useOptimistic quietly saved me
Three real cases from the last few months, roughly in order of how much time each one saved me.
Toggle switches on a settings page. Users flip a switch, we PATCH the setting, then re-render from the returned config. Old code had a race where fast toggling would flip back and forth as older responses landed after newer ones. useOptimistic serializes the optimistic view through the transition queue, so the last click wins visually and the server response reconciles cleanly.
“Mark as read” on a notification list. A user inbox with 40 notifications. Old code updated local state, then swapped after the batch mutation. Ugly loading skeleton whenever they marked 10 at once. useOptimistic marks them instantly, keeps them styled as “just read” until the request finishes, then lets the server version take over without a flash.
Delete-with-undo. This is the sneaky win. When I addOptimistic({ deleted: true }) and render deleted items with a strikethrough and an Undo button, if the user hits Undo before the transition resolves, I can cancel the pending action and the optimistic state clears itself. No manual “did the undo happen before the delete” bookkeeping.
If you want more of the framework side of this, I wrote about the Next.js server actions pattern I actually ship a few weeks back. useOptimistic pairs with server actions the way React Query pairs with fetch.
Gotchas I hit in the first two weeks
Only inside a transition. I called addOptimistic from a plain button click handler and React silently ignored it. Half an hour of “why is nothing rendering” before I read the fine print. You need a form action, useActionState, or an explicit startTransition(() => ...) wrapper.
The reducer runs on every render during the transition. If your reducer allocates a big array, you allocate it repeatedly. I moved a .map over the full list into the reducer once and burned a few frames on a large table. Keep the reducer O(k) where k is the size of your update, not the size of the whole list.
Server state must be authoritative. I tried once to use useOptimistic as a local-only “pretend it saved” hack for a form without any real server behind it. Don’t. When the transition finishes and the passed-in state hasn’t changed, React drops the optimistic update and your UI reverts. Use useState for local-only, not this hook.
It does not deduplicate. If you addOptimistic twice for the same logical action, you’ll see two entries. I now generate the temp id from a stable key when I need dedupe.
TypeScript inference is picky. The reducer’s action parameter defaults to the state type, which is almost never what you want. I annotate it explicitly:
useOptimistic<Comment[], NewComment>(comments, (state, action) => [
...state,
{ ...action, pending: true },
]);
When I still don’t reach for it
Two cases where I stick with a plain pattern instead.
If the mutation is fire-and-forget and doesn’t affect a visible list, useOptimistic is overkill. A regular useTransition with a pending flag is smaller and reads better.
If I need the optimistic-looking state to last longer than the transition, I still use useState. useOptimistic snaps back the second the transition resolves. That is exactly what you want most of the time and exactly what you don’t want when the flow is “show the new item highlighted for three seconds after save completes”. For that, I keep useState for the highlight and let useOptimistic handle only the placeholder.
I also don’t use it in server components. It’s a client-only hook, and if you try, the build fails with a clear error. Not a real gotcha, just worth knowing before you refactor.
Try this Monday
If there is any place in your app where the user clicks something and then waits, take 20 minutes to try this:
- Find a small mutation with a visible list: a toggle on a settings page, a mark-as-read on notifications, a delete with confirmation, or an inline edit on a table row.
- Wrap the current call in a form action or
startTransition. - Replace the manual optimistic bookkeeping with
useOptimisticand a reducer that appends a pending marker. - Test the failure path by throwing on the server on purpose. Watch it revert cleanly.
If it feels good, migrate one component at a time. That is how I did it. A month later, the useState + snapshot + rollback pattern was gone from the codebase and the loading-spinner tickets stopped showing up in QA. The companion piece on the React 19 use hook I now reach for sits next to this one if you want more of the same. And if this “here is what I actually ship” tone is useful, more of it lives at my portfolio and blog.