Optimistic UI Updates
Showing the expected result immediately and reconciling when the server responds.
UX PatternsIntermediate
Why
Waiting 300ms for a like-toggle's server round-trip feels laggy. Optimistic UI applies the change instantly, then rolls back on error — perceived performance improves even when the network is slow.
Nielsen Norman Group research shows users spend an average of 10-15 seconds on a page before leaving. Clear visual hierarchy and progressive disclosure improve task completion rates by 40-60%.
When
Use for low-risk, easily-reversible actions: likes, stars, read/unread, checkbox toggles. Avoid for irreversible actions (delete, payment) or where stale data causes harm (balance, inventory).
How
1. Apply locally — mutate the cache/UI to the expected state immediately, before the request
2. Send the request — fire the POST/PUT in the background
3. On success — reconcile: server may return the canonical state (e.g. new like count), replace local
4. On error — rollback to the previous state, show a toast ("Couldn't like — retry")
5. Conflict — if the server says the state already changed, refetch and show the truth
6. Library — React Query's onMutate + onError rollback, SWR's optimisticData, or hand-rolled
7. Idempotency — make the action idempotent (like-toggle, not like-increment) so retries are safe
Example
// React Query — optimistic like toggle
useMutation({
mutationFn: (liked: boolean) => api.put(`/posts/${id}/like`, { liked }),
onMutate: async (liked) => {
await queryClient.cancelQueries({ queryKey: ["post", id] });
const prev = queryClient.getQueryData(["post", id]);
queryClient.setQueryData(["post", id], (p) => ({ ...p, liked })); // optimistic
return { prev }; // context for rollback
},
onError: (_e, _v, ctx) => {
queryClient.setQueryData(["post", id], ctx?.prev); // rollback
toast.error("Couldn't update — reverted");
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ["post", id] }),
});
// Hand-rolled — same pattern
const [liked, setLiked] = useState(post.liked);
const toggle = async () => {
const prev = liked;
setLiked(!prev); // optimistic
try { await api.put(`/posts/${id}/like`, { liked: !prev }); }
catch { setLiked(prev); toast.error("Reverted"); } // rollback
};
Common Problems
- ⚠No rollback on error — UI shows the optimistic state forever, diverges from server
- ⚠Used for irreversible actions — user 'deletes' optimistically, server fails, data is gone in UI but not server
- ⚠Non-idempotent action retried — double-like on retry, server count is wrong
Best Practices
- ✓Always provide an onError rollback — the optimistic state must revert if the request fails
- ✓Only use for reversible actions — likes, stars, toggles; never delete or pay optimistically
- ✓Make the action idempotent — a like-toggle (not increment) so retries don't double-count