Optimistic UI Updates in React: Patterns and Rollback
Advanced5 minInnovialabs editorial team
In short
Optimistic UI shows the expected result immediately and syncs with the server in the background; it needs a snapshot for rollback, protection from races and duplicates, and should never be used where a false "success" costs money.
What optimistic UI is and when it pays off
Optimistic UI means changing the interface as if the request has already succeeded, then confirming or reverting it when the server answers. A like turns red instantly, a todo appears in the list without a spinner, a renamed file shows its new name right away.
It works well when three conditions hold:
- the operation almost always succeeds (validation already passed on the client);
- the result is easy to predict from the input;
- a failure is cheap to undo visually and does not mislead the user about something important.
If any of these is false, show an honest pending state instead.
useOptimistic: the built-in React approach
React 19 ships the useOptimistic hook. It returns a temporary state that exists only while an action or transition is running; when it finishes, React switches back to the real state you passed in.
import { useOptimistic } from "react";
function TodoList({ todos, addTodo }) {
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(current, draft) => [...current, { ...draft, pending: true }]
);
async function formAction(formData) {
const draft = { id: crypto.randomUUID(), text: formData.get("text") };
addOptimistic(draft);
try {
await addTodo(draft); // must update `todos` in the parent on success
} catch {
showToast("Could not save the task");
}
}
return (
<form action={formAction}>
<input name="text" required />
<ul>
{optimisticTodos.map((t) => (
<li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}>{t.text}</li>
))}
</ul>
</form>
);
}
Key points:
- Rollback is automatic: if the action fails, the optimistic item disappears as soon as the transition ends, because the real
todosnever changed. - The flip side: on success, the real state must also update (revalidation,
setStatein the parent, a Server Action withrevalidatePath). Otherwise the item vanishes after the action completes. addOptimisticmust be called inside an action orstartTransition, not in an ordinary event handler.
TanStack Query: snapshot, update, rollback
When data lives in the TanStack Query cache, the classic pattern is built on onMutate:
const queryClient = useQueryClient();
const addTodo = useMutation({
mutationFn: (todo: Todo) => api.addTodo(todo),
onMutate: async (todo) => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previous = queryClient.getQueryData<Todo[]>(["todos"]);
queryClient.setQueryData<Todo[]>(["todos"], (old = []) => [...old, todo]);
return { previous };
},
onError: (_err, _todo, ctx) => {
queryClient.setQueryData(["todos"], ctx?.previous);
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
});
The steps:
- Cancel in-flight queries so an older refetch does not overwrite the optimistic data.
- Save a snapshot of the cache.
- Write the expected result into the cache.
- On error, restore the snapshot and tell the user.
- In any case, refetch to get the source of truth.
A simpler option: skip the cache entirely and render mutation.variables as a semi-transparent item while isPending is true. Nothing to roll back — on error the item just disappears.
Race conditions and duplicates
Optimistic UI makes concurrency problems more visible. Typical cases:
| Problem | What happens | How to handle |
|---|---|---|
| Stale refetch | A query started before the mutation returns old data | cancelQueries before the update |
| Parallel mutations | Rolling back one restores a snapshot that erases another | Undo only your own change (filter by id), not the whole snapshot |
| Out-of-order responses | The answer to the first click arrives after the second | Send the final state ("liked: true"), not a delta; compare versions |
| Early invalidation | Refetch after the first mutation wipes the others' optimistic data | Invalidate only when isMutating for that key drops to one |
| Double submit | The user clicks again because "it already worked" | Client-generated id or idempotency key, server deduplicates |
Client-generated IDs solve two problems at once: there is no need to swap a temporary id for the real one, and a repeated request with the same id is easy to deduplicate on the backend.
Where optimistic UI is dangerous
Do not pretend success when a mistake has real consequences:
- payments, transfers, balance top-ups — the user may close the tab believing the money has been sent;
- orders with limited stock and bookings, where the server decides availability;
- irreversible actions: sending an email, publishing, permanent deletion;
- operations with complex server-side validation whose outcome cannot be predicted.
For these, use an explicit pending state: a disabled button, a progress indicator, and a confirmation screen only after the server responds.
Common mistakes
- No error message: the item quietly disappears and the user does not understand why.
- Rolling back the whole cache in parallel mutations and losing other people's changes.
- Forgetting the final refetch, so the client and server drift apart.
- Optimistic deletion without an "Undo" option for important data.