Lesson 53 of 80
Update the screen as if the server already said yes, then quietly undo it if the server says no — the pattern behind every UI that feels instant.
15 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
Every server request has a gap between "user clicked" and "server confirmed." The honest way to handle that gap is a loading spinner. The optimistic way is to assume success, update the UI immediately, and only deal with the server's actual answer once it arrives — rolling back if it turns out you were wrong.
This isn't lying to the user so much as making a good bet. Liking a post, checking off a todo, sending a chat message — these succeed the overwhelming majority of the time, so paying the "wait for confirmation" cost on every single one, every single time, is optimizing for the rare case at the expense of the common one. The failure path still has to exist; it just runs in the background instead of blocking the interaction.
import { useState } from "react";
function TodoItem({ todo, onToggle }) {
const [items, setItems] = useState([todo]);
const [error, setError] = useState(null);
async function handleToggle() {
const previous = items; // snapshot, in case we need to undo
const optimistic = items.map((t) =>
t.id === todo.id ? { ...t, done: !t.done } : t
);
setItems(optimistic); // update the screen immediately
setError(null);
try {
await onToggle(todo.id); // the real request, running after the UI already moved
} catch (err) {
setItems(previous); // server said no — roll back
setError("Couldn't save that change. Try again.");
}
}
const current = items.find((t) => t.id === todo.id);
return (
<li>
<label>
<input type="checkbox" checked={current.done} onChange={handleToggle} />
{current.text}
</label>
{error && <span role="alert">{error}</span>}
</li>
);
}
The shape is always the same three steps: snapshot the current state, apply the change as if it already succeeded, then either discard the snapshot (on success) or restore it (on failure). React 19's useOptimistic hook automates exactly this pattern for state that's derived from an async action — you give it a base value and a reducer for merging in the optimistic version, and it handles reverting automatically once the underlying action settles:
import { useOptimistic } from "react";
function TodoItem({ todo, toggleAction }) {
const [optimisticTodo, setOptimisticTodo] = useOptimistic(
todo,
(state, done) => ({ ...state, done })
);
return (
<form action={async () => {
setOptimisticTodo(!optimisticTodo.done);
await toggleAction(todo.id); // if this throws, React reverts automatically
}}>
<button>{optimisticTodo.done ? "Undo" : "Complete"}</button>
</form>
);
}
Optimistic UI only makes sense once you've internalized that client state and server state are two different things that happen to usually agree — the todo's done flag in your component is a local copy, not the source of truth, and the whole pattern is just being explicit about the moment those two copies can disagree and how you reconcile them when they do.