A user clicks the like button. The count goes from 142 to 143. A second later, with no warning, it slides back to 142. The network call failed. They never liked anything. They don't know that.
That's the lie most optimistic UI tutorials teach you to tell. The pattern is right - show the result before the server confirms it - but the implementation is built to disappear failure, not handle it. When things go wrong, the UI pretends nothing happened.
This post is about doing it the other way. The state has three flavors, not one. The user always knows what's confirmed and what isn't. And rollback is a deliberate, visible thing - not a magic trick.
Here's the version you'll find in 90% of blog posts:
function LikeButton({ initial }: { initial: number }) {
const [likes, setLikes] = useState(initial);
async function handleClick() {
setLikes(likes + 1);
try {
await fetch("/api/like", { method: "POST" });
} catch {
setLikes(likes); // "rollback"
}
}
return <button onClick={handleClick}>♥ {likes}</button>;
}
Four things are wrong with this.
No in-flight indicator. The user can't tell their click is doing anything until either it succeeds (no visible change because the optimistic update already happened) or it fails (silent revert). The whole interaction reads as "instant success" or "nothing."
Stale closure rollback.
setLikes(likes) captures the value from the render where
handleClick was created. Click twice quickly and the rollback
resets to the wrong number.
No double-click protection. Two fast clicks fire two requests. If only one fails, the count is off by one, in either direction, and there's no signal which direction.
No conflict resolution. If the server returns
{ likes: 200 } because someone else liked the post in another
tab, you have no path for that information to land in the UI.
The "before" button silently reverts. The "after" button is honest about every state. Click each one a few times - failure is forced ~60% of the time so you don't have to wait long.
The fix starts with admitting that an optimistic value is not the same kind of thing as a confirmed value. They look the same on screen, but they have different rules. Encode that in the type:
type Optimistic<T> =
| { status: "confirmed"; value: T }
| { status: "pending"; optimistic: T; previous: T }
| { status: "failed"; optimistic: T; previous: T; error: Error };
Three variants, three jobs.
confirmed is the only state the server has agreed to. It
carries one value and nothing else.
pending carries two: the optimistic value (what
the UI shows right now) and the previous value (what the
server last confirmed). previous exists for one reason: it's
the rollback target. You only need it while a request is in flight.
failed is pending plus the error. The optimistic
value stays visible - we don't yank it away from the user - but now the UI
has license to render it differently and offer a retry. The
previous value is still there in case the user gives up.
Notice what's not in the type: there is no "loading then maybe rollback" state where the UI is unsure which value to show. At every moment, there is exactly one value to render and exactly one secondary thing to do with it (wait, retry, or leave alone).
Here's the hook. It's about 40 lines and has no dependencies.
import { useCallback, useRef, useState } from "react";
type Optimistic<T> =
| { status: "confirmed"; value: T }
| { status: "pending"; optimistic: T; previous: T }
| { status: "failed"; optimistic: T; previous: T; error: Error };
export function useOptimistic<T>(initial: T, mutator: (next: T) => Promise<T>) {
const [state, setState] = useState<Optimistic<T>>({
status: "confirmed",
value: initial,
});
const latest = useRef(0);
const mutate = useCallback(
async (next: T) => {
const previous =
state.status === "confirmed" ? state.value : state.optimistic;
const id = ++latest.current;
setState({ status: "pending", optimistic: next, previous });
try {
const confirmed = await mutator(next);
if (id !== latest.current) return; // a newer call won
setState({ status: "confirmed", value: confirmed });
} catch (error) {
if (id !== latest.current) return;
setState({
status: "failed",
optimistic: next,
previous,
error: error as Error,
});
}
},
[state, mutator]
);
const retry = useCallback(() => {
if (state.status !== "failed") return;
void mutate(state.optimistic);
}, [state, mutate]);
const rollback = useCallback(() => {
if (state.status !== "failed") return;
setState({ status: "confirmed", value: state.previous });
}, [state]);
return { state, mutate, retry, rollback };
}
A few things to notice.
The latest ref is the double-click fix. Every call to
mutate gets a monotonically increasing id. When a request
resolves, it checks whether it's still the most recent call. If not, it
throws its result away. The user clicks five times in a second; only the
fifth click's result lands. No interleaved updates, no flicker.
The previous value is computed at the start of each mutation,
not at hook creation. If you mutate while a previous mutation is still
pending, the rollback target is the optimistic value of the prior call -
the most recent thing the user saw - not some stale snapshot from before
the chain started.
retry and rollback are explicit. The user is in
charge of failed states. The hook never silently makes a decision about a
failure that the user can see.
Sometimes the server doesn't fail - it just returns a different value than
you expected. Another tab incremented the count. A moderator deleted three
likes. Your optimistic 143 is met with a confirmed
199.
The hook above already handles this correctly: on success, it replaces
state with whatever the server returned, not the optimistic value. So if
you mutate(143) and the server says 199, the UI
flips to 199 after the request resolves.
But sometimes "trust the server" is wrong. Imagine a text input that the user is actively typing in. The server's confirmed value is two seconds old. Slamming it back into the UI mid-keystroke is worse than the conflict.
For that, take a reconcile callback:
export function useOptimistic<T>(
initial: T,
mutator: (next: T) => Promise<T>,
reconcile: (server: T, optimistic: T) => T = (server) => server
) {
// ...
const confirmed = await mutator(next);
if (id !== latest.current) return;
setState({
status: "confirmed",
value: reconcile(confirmed, next),
});
// ...
}
Default behavior: trust the server. Pass
(_, optimistic) => optimistic to trust the client. Pass a
real merge function for things like collaborative text. The point is the
decision is at the call site, not buried in the hook.
Network failure has two flavors that feel the same to your
try/catch but feel very different to the user. A
500 from the server is one thing. "Your phone is in a tunnel" is another.
The first is a real failure. The second is a deferral.
You can tell them apart cheaply:
function isOffline(error: unknown): boolean {
if (typeof navigator !== "undefined" && navigator.onLine === false) {
return true;
}
return error instanceof TypeError && /fetch/i.test((error as Error).message);
}
navigator.onLine is unreliable on its own (it reports the
OS-level link state, not whether the internet works), so use it as a hint,
not a verdict. The TypeError from fetch is the
actual signal that the request never left the device.
Then surface it differently:
{
state.status === "failed" &&
(isOffline(state.error) ? (
<span>Queued - will retry when you're back online.</span>
) : (
<button onClick={retry}>Retry</button>
));
}
For automatic retry on reconnection, listen for the
online event in the component that owns the failed state and
call retry() once. Don't put this in the hook - what to do
when the network comes back is a product decision, not a primitive.
Toggle the switches and click. The status pill always shows which variant of the union you're in. The count never lies - when it's optimistic, it says so; when it's failed, it stays put with a retry; when it's confirmed, the badge goes away.
Map this back to the type. There is no state in this UI that the type doesn't describe, and there is no variant of the type that the UI doesn't render. That's the whole point.
Optimistic UI is for fast, frequent, low-stakes mutations. Likes. Toggles. Reordering a list. Marking something as read. The user does a thing dozens of times a day, the failure rate is below 1%, and the cost of being wrong for half a second is zero.
Don't use it for payments. Don't use it for deletes. Don't use it for anything where "I thought that worked but it didn't" is a real harm. For those, a clear pending state is the entire UI affordance - the user is supposed to wait, and lying about it removes information they need.
The mental model is one line: only lie about things you can take back, and tell the truth the moment you can't.