Optimistic UI with useOptimistic
useOptimistic shows a result immediately while an action is pending and reverts automatically on failure — the safe shape is snapshot, apply optimistic, then reconcile or roll back with visible error feedback, and never for operations the user must not believe are done.
A user taps the heart on a post. The like has to travel to a server, hit a database, and come back — 300ms on a good connection, two seconds on the subway. If the heart only fills after the round trip, the app feels broken even when nothing is wrong. So you fill it instantly and let the request catch up. That instinct is right, and useOptimistic is the hook that lets you do it without hand-rolling rollback logic.
But “show it instantly” has a sharp edge. The request can fail. If the heart stays filled after a 500, your UI is now lying about a fact — and the most dangerous version of this is being optimistic about something the user must never wrongly believe is done, like a payment. This lesson is the safe shape of the pattern, and the line you don’t cross.
After this lesson you can use useOptimistic to show a pending mutation immediately and have React revert it automatically when the action ends; structure any optimistic update as snapshot → apply optimistic → reconcile or roll back with visible error feedback; explain why useOptimistic state only exists for the lifetime of the action and what that buys you; and name the operations you must not be optimistic about — anything where a false “done” causes real harm (payments, irreversible actions).
useOptimistic layers a temporary, optimistic value over your real state for exactly as long as an action is pending — then it vanishes and the real state shows through. You give it the current confirmed state and a reducer that produces the optimistic version. While a transition/action is running, reads return the optimistic value; the moment the action settles, React discards the optimistic layer and you see whatever the real state now is.
"use client";
import { useOptimistic, useState, startTransition } from "react";
function LikeButton({ post }: { post: { id: string; likes: number; likedByMe: boolean } }) {
const [liked, setLiked] = useState(post.likedByMe);
const [count, setCount] = useState(post.likes);
// optimistic state is derived from the real state + a pending delta
const [optimistic, setOptimistic] = useOptimistic(
{ liked, count },
(state, nextLiked: boolean) => ({
liked: nextLiked,
count: state.count + (nextLiked ? 1 : -1),
}),
);
// ...
}The key mental model: optimistic is not a second source of truth you have to keep in sync. It’s a view that React paints on top of the real value while the work is in flight, and removes on its own when the work is done.
The safe shape is always three moves: snapshot the truth, apply the optimistic update, then reconcile or roll back — with the error path visible, not silent. The snapshot is implicit here: your real useState/server data is the snapshot, and useOptimistic never mutates it. You apply the optimistic update inside a transition, do the real mutation, and on success commit it to real state; on failure you do nothing to real state (so the optimistic layer simply disappears and the truth reappears) and you surface the error.
const [error, setError] = useState<string | null>(null);
function toggle() {
const next = !liked;
setError(null);
startTransition(async () => {
setOptimistic(next); // 2. apply optimistic — heart fills NOW
try {
const res = await likePost(post.id, next); // 3a. real mutation
setLiked(next); // commit truth on success
setCount(res.likes); // reconcile with the server's number
} catch {
setError("Couldn't update like — tap to retry"); // 3b. visible revert
// real state untouched → optimistic layer drops → heart un-fills
}
});
}Reconcile means re-sync with what the server actually said (res.likes may differ from your guess if another user also liked). Roll back means: don’t touch real state, let React peel the optimistic layer, and tell the user.
The failure mode that defines this pattern: an optimistic update with no rollback or error path. The UI then lies whenever the request fails. This is easy to ship because the happy path looks perfect in dev where requests never fail. The smell is a setOptimistic(...) followed by a fire-and-forget mutation with no catch, or a catch {} that swallows the error. When the network drops, the heart stays filled, the item stays in the list, the count is wrong — and the user has no idea their action didn’t take.
// BROKEN: optimistic with no error path — the UI lies on failure
function toggle() {
startTransition(async () => {
setOptimistic(!liked);
setLiked(!liked); // commits truth BEFORE the server confirms
await likePost(post.id, !liked).catch(() => {}); // swallowed → silent lie
});
}The fix is structural, not cosmetic: never commit to real state before the server confirms, and always have a catch that gives the user feedback. Optimism without a revert path isn’t optimism — it’s a bug that hides itself.
Don’t be optimistic about operations the user must not wrongly believe are done — payments, deletes-with-consequences, anything irreversible. Optimistic UI trades correctness-of-the-moment for speed, betting the action will succeed. That bet is fine for a like or adding a to-do: if it fails, the user shrugs and retries. It is not fine for “Pay $400” or “Delete account”, where a false “Done ✓” leads the user to walk away believing something happened that didn’t. For those, show a real pending state (spinner, disabled button) and only confirm after the server confirms.
// like / add-to-list → optimistic is great (cheap to be wrong, instant feel)
// "Pay now" / "Delete forever" → NEVER optimistic; the user acts on a false truth
// use a pending UI instead:
const [isPending, startTransition] = useTransition();
<button disabled={isPending}>{isPending ? "Charging…" : "Pay $400"}</button>The senior judgment is per-action: ask “if this optimistic update turns out to be wrong, what does the user do with that false belief?” If the answer is “nothing bad”, be optimistic. If it’s “they think they paid”, do not.
Add-to-list, before and after — the difference is entirely the revert path. A “save for later” list appends an item via a server action. Below is the naive version everyone writes first.
// BEFORE: optimistic append, no rollback — lies on failure
function SaveList({ items }: { items: Item[] }) {
const [list, setList] = useState(items);
const [optimistic, addOptimistic] = useOptimistic(
list,
(state, item: Item) => [...state, item],
);
function add(item: Item) {
startTransition(async () => {
addOptimistic(item);
setList((l) => [...l, item]); // commits before the server confirms
await saveItem(item); // if this throws, item is wrongly kept
});
}
return <Rows items={optimistic} onAdd={add} />;
}If saveItem rejects, the item is already in list, so it stays forever — the UI claims a save that never happened, with no error shown. Now the safe version: real state is committed only on success, and failure is surfaced.
// AFTER: snapshot (list) untouched until success; visible revert on failure
function SaveList({ items }: { items: Item[] }) {
const [list, setList] = useState(items);
const [error, setError] = useState<string | null>(null);
const [optimistic, addOptimistic] = useOptimistic(
list,
(state, item: Item) => [...state, item],
);
function add(item: Item) {
setError(null);
startTransition(async () => {
addOptimistic(item); // appears instantly
try {
const saved = await saveItem(item); // reconcile with server's record
setList((l) => [...l, saved]); // commit truth (server may add an id)
} catch {
setError("Couldn't save — item removed, try again");
// list never changed → optimistic layer drops → row disappears
}
});
}
return (
<>
{error && <p role="alert" className="error">{error}</p>}
<Rows items={optimistic} onAdd={add} />
</>
);
}Same instant feel, but now a failed save retracts itself and tells the user. The optimistic row was only ever a layer over list; because we never committed to list on failure, React removes it automatically when the action ends. That self-healing revert is the entire reason to use useOptimistic instead of manually pushing to and splicing from real state.
▸Why this works
Why does useOptimistic not need an explicit “undo” call? Because the optimistic value is derived, not stored. It only exists while a transition is pending; React recomputes it from your real state through the reducer, and when the action settles it stops applying the reducer entirely — so reads fall back to the real state. If you instead pushed the optimistic item into a real useState array, you would own the rollback and would have to remember to splice it out on every error path. Deriving the optimistic layer means the revert is automatic and impossible to forget — which is exactly why the safe pattern is “leave real state alone on failure”.
▸Common mistake
The subtle mistake is reconciling wrong: on success, committing your guessed optimistic value instead of the server’s actual response. Your optimistic count was count + 1, but between your read and your write another user also liked the post — the server says count + 2. If you commit your guess, the real state is now silently wrong and will only correct on a refetch. Always reconcile from the server’s reply (res.likes, saved.id), not from the optimistic guess. Optimistic UI is a temporary lie you tell the user; reconciliation is where you make it true.
A 'Pay $400' button uses useOptimistic to instantly show 'Payment complete ✓' the moment it's tapped, then runs the charge in the background with a catch that logs to the console. What's the core problem?
useOptimistic paints a temporary, derived value over your real state for exactly as long as an action is pending, then peels it off automatically — which is what makes mutations feel instant without a manual undo. The safe shape is always the same three moves: snapshot (your real state, which you never mutate optimistically), apply the optimistic update inside a transition, then reconcile from the server’s actual reply on success or roll back on failure — and the roll back must be visible, surfaced to the user, not a swallowed catch. The pattern’s defining failure mode is an optimistic update with no error path: the UI keeps showing a success that never happened and lies silently. And the hard line is the per-action judgment: never be optimistic about operations the user must not wrongly believe are done — payments and irreversible actions get a real pending state, because a false “done” there isn’t a cosmetic glitch, it’s the user acting on something that didn’t happen.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.