A few months ago I shipped a “like” button that felt broken. You’d tap it, nothing would happen for half a second, then the heart would fill in. Functionally correct. It felt awful. Every tester said the same thing: “is it working?” The button was waiting for the server round-trip before it showed anything, and half a second of nothing reads as a bug to a human thumb.
The fix is optimistic UI: show the result immediately, assume the server will agree, and roll back if it doesn’t. I’d built this by hand before and it was always fiddly. React 19 shipped a hook called useOptimistic that handles the annoying parts, and it’s one of the few new APIs I adopted the same week I read about it. Here’s how I used to do it, how I do it now, and the places it bites you.
The hand-rolled version, and why it got messy
Before the hook, optimistic updates meant keeping a shadow copy of state and reconciling it yourself. For that like button, my old code looked roughly like this:
function LikeButton({ postId, initialLikes }) {
const [likes, setLikes] = useState(initialLikes);
const [pending, setPending] = useState(false);
async function handleLike() {
setPending(true);
setLikes(likes + 1); // optimistic bump
try {
const real = await likePost(postId);
setLikes(real); // trust the server
} catch {
setLikes(likes); // roll back
} finally {
setPending(false);
}
}
// ...
}
It works, mostly. But I’m tracking three things by hand: the displayed count, the pending flag, and the rollback value. The bugs always came from that rollback line. If a second click landed before the first resolved, my likes closure was stale and the rollback restored the wrong number. I shipped that bug more than once, and it’s the kind of bug that never shows up in a calm demo and always shows up when a real user gets impatient and double-taps.
What useOptimistic actually does
The hook gives you a temporary state that automatically reverts when the surrounding async action finishes. The React docs for useOptimistic describe the signature as const [optimistic, addOptimistic] = useOptimistic(actualState, updateFn). You render optimistic, you call addOptimistic when you kick off the action, and React snaps back to actualState once the action settles. You don’t write the rollback. That’s the whole pitch, and it’s the part I kept getting wrong by hand.
Same button, rewritten:
function LikeButton({ postId, likes, likeAction }) {
const [optimisticLikes, addOptimistic] = useOptimistic(
likes,
(current, amount) => current + amount
);
return (
<form action={async () => {
addOptimistic(1);
await likeAction(postId);
}}>
<button type="submit">♥ {optimisticLikes}</button>
</form>
);
}
No pending flag I manage, no rollback value I stash, no stale closure restoring a wrong count. When the action resolves, the parent’s likes updates and the optimistic value falls away on its own.
It only works inside an action
This is the part that tripped me up, and the docs are a little quiet about it. useOptimistic reverts when a transition or action completes. If you call addOptimistic outside of one, the optimistic value shows up and then never goes away, because there’s no action boundary to tell React when to revert.
In practice that means you’re using it with form actions or useTransition. The cleanest pairing is with useActionState, which React 19 introduced alongside it and documents in the same reference set. I wrote about how those form APIs changed my approach in my post on React 19 forms, and useOptimistic slots right into that pattern. If you’re firing updates from a plain onClick with no transition, wrap the work in startTransition first, or the revert won’t happen.
I burned an hour on this exact thing. I had an optimistic counter wired to a button’s onClick, no transition anywhere, and the number kept climbing and never resetting. It looked like a state bug deep in my code. It was just me not giving React an action to anchor the revert to. The moment I moved the call into a form action, it behaved.
A more honest example: a message list
The like button is the toy version. The case where this hook genuinely earned its place was a chat list, where I want a sent message to appear instantly while it’s still in flight:
function Thread({ messages, sendAction }) {
const [optimisticMessages, addMessage] = useOptimistic(
messages,
(current, text) => [...current, { text, sending: true }]
);
async function formAction(formData) {
const text = formData.get("text");
addMessage(text);
await sendAction(text);
}
return (
<>
{optimisticMessages.map((m, i) => (
<div key={i} style={{ opacity: m.sending ? 0.5 : 1 }}>
{m.text}
</div>
))}
<form action={formAction}>
<input name="text" />
</form>
</>
);
}
The new message shows up greyed out the instant you hit send, then solidifies when the server confirms. If the send throws, the optimistic entry vanishes on its own because the action ended. This is the behavior every chat app has, and I used to write it with a pile of state and a manual cleanup. Now it’s one reducer function.
The reducer is the part worth slowing down on
The second argument to useOptimistic is a reducer, and I glossed over it the first time. It takes the current state and whatever you pass to addOptimistic, then returns the next optimistic state. The detail I missed: React can run it more than once. If a user fires three quick actions, React replays the reducer over the real base state for each pending update, in order, so the optimistic value reflects all of them at once. That’s exactly the case my hand-rolled version got wrong with its stale closure.
Two rules follow from that. Keep the reducer pure, with no fetch calls or side effects inside it, because React may run it repeatedly. And make it work off the current value it’s handed, not a variable captured from the surrounding scope. Once I understood that the reducer is replayed rather than called once, the whole hook stopped surprising me. It behaves like every other reducer in React, which turns out to be the entire point.
The places it still bites
I don’t want to oversell it. The release that introduced this hook, React 19, bundles it with a lot of other action machinery, and you kind of have to buy into that model for it to feel natural. If your app isn’t using actions or transitions yet, useOptimistic on its own will feel like it’s fighting you.
It’s also genuinely optimistic, which means it assumes success. If your failure rate is high, flashing a result and yanking it back is worse than a short spinner. I keep optimistic UI for actions that almost always succeed, like a like or a sent message, and I leave slow, failure-prone operations alone with an honest loading state. The other thing I watch is the revert itself: if a message can disappear after a failed send, tell the user it failed, because a silent vanish is its own kind of broken.
Try it on your worst-feeling button
Find the one interaction in your app that feels laggy even though the code is correct. The favorite toggle, the upvote, the little thing users tap and then tap again because nothing happened. Wrap it in a form action and a useOptimistic reducer, and watch it go from “is this working” to instant. That single change does more for perceived speed than most of the performance work I’ve done. If you want more of how I think about frontend feel, it’s scattered across my portfolio.