Waiting for the server before showing a change makes a UI feel sluggish. Optimistic UI updates the screen immediately and reconciles with the server after.
1. User clicks "Done"
2. Update the UI instantly (optimistically)
3. Send the request to the server
4. On success → keep the change
5. On failure → roll back + show an error
1async function handleDelete(id: string) {
2 const snapshot = tasks; // remember current state
3 setTasks((prev) => prev.filter((t) => t.id !== id)); // remove instantly
4 try {
5 const res = await fetch(`/api/v1/tasks/${id}`, { method: "DELETE" });
6 if (!res.ok) throw new Error();
7 } catch {
8 setTasks(snapshot); // roll back on failure
9 toast.error("Couldn't delete — restored.");
10 }
11}The list updates the moment the user clicks. If the server rejects it, the row reappears and the user is told.
1useMutation({
2 mutationFn: (id) => fetch(`/api/v1/tasks/${id}`, { method: "DELETE" }),
3 onMutate: async (id) => {
4 await qc.cancelQueries({ queryKey: ["tasks"] });
5 const prev = qc.getQueryData(["tasks"]);
6 qc.setQueryData(["tasks"], (old) => old.filter((t) => t.id !== id));
7 return { prev }; // context for rollback
8 },
9 onError: (_e, _id, ctx) => qc.setQueryData(["tasks"], ctx.prev),
10 onSettled: () => qc.invalidateQueries({ queryKey: ["tasks"] }),
11});Optimism assumes success is likely and rollback is cheap. Avoid it for:
For those, show a spinner and wait for the truth.