Rolling Back Optimistic Updates on Error
Optimistic updates paint the mutation’s expected result into the cache before the server has acknowledged it, so the UI feels instant. The failure mode is what happens when the request rejects: without a rollback, the cache keeps the optimistic write forever, and the user sees a “liked” post or a “sent” message that the server never actually recorded. This page is a concrete recipe within Mutation Sync & Rollback, and it walks through the exact TanStack Query v5 onMutate / onError / onSettled lifecycle that makes rollback deterministic. If your failures are network-partition failures rather than server rejections, the companion pattern in Syncing Offline Mutations on Reconnect queues the mutation instead of rolling it back.
The root cause of most broken rollbacks is a missing or mis-ordered snapshot: teams call setQueryData optimistically but never capture the previous value, or they capture it after an in-flight refetch has already mutated the cache. The fix is a strict four-phase context handoff between the mutation callbacks.
Diagnostic Checklist
Before writing any code, confirm your symptoms match this failure mode:
- A failed mutation leaves the optimistic value on screen — the toggle stays flipped, the new row stays in the list — until a manual refresh.
- The optimistic value briefly reverts to the old server value mid-flight, then snaps back to the optimistic value, because a background refetch resolved after your write.
onErrorfires but the UI does not revert, becausecontextisundefinedor the snapshot was never returned fromonMutate.- Two rapid clicks produce a rollback to a state that still contains the other click’s optimistic write.
- TanStack Query DevTools shows the query as
fetchingat the exact moment your optimisticsetQueryDataruns.
Step-by-Step Implementation
Step 1 — Cancel in-flight queries before touching the cache
The first line of onMutate must await queryClient.cancelQueries for the key you are about to mutate. This aborts any background refetch already in flight so it cannot resolve after your optimistic write and clobber it with stale server data.
// hooks/use-toggle-like.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface Post {
id: string;
liked: boolean;
likeCount: number;
}
const postKey = (id: string) => ['post', id] as const;
async function toggleLike(id: string): Promise<Post> {
const res = await fetch(`/api/posts/${id}/like`, { method: 'POST' });
if (!res.ok) throw new Error('Toggle failed');
return res.json();
}
Cache Behavior Analysis. Defining a stable postKey factory means cancelQueries, getQueryData, setQueryData, and invalidateQueries all address the exact same cache entry. A mismatched key here is the single most common cause of a snapshot that captures undefined and a rollback that silently no-ops.
Step 2 — Snapshot, write, and hand off the context
Inside onMutate, cancel first, read the current cache with getQueryData, apply the optimistic write with a functional setQueryData updater, then return the snapshot. Whatever you return becomes the context argument passed to onError and onSettled.
export function useToggleLike(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => toggleLike(id),
async onMutate() {
// 1. Stop in-flight refetches from overwriting the optimistic write
await queryClient.cancelQueries({ queryKey: postKey(id) });
// 2. Snapshot the exact value we will restore on failure
const previous = queryClient.getQueryData<Post>(postKey(id));
// 3. Optimistically update — functional updater avoids stale closures
queryClient.setQueryData<Post>(postKey(id), (old) =>
old
? { ...old, liked: !old.liked, likeCount: old.likeCount + (old.liked ? -1 : 1) }
: old,
);
// 4. Hand the snapshot to onError/onSettled via context
return { previous };
},
// onError / onSettled defined in the next steps
});
}
Cache Behavior Analysis. getQueryData returns the current cached value by reference — it is a shallow read, so the snapshot shares nested object references with the live cache. Because the setQueryData updater spreads into a new object rather than mutating old, the snapshot’s reference stays pointed at the pre-mutation object and remains a valid restore target. Returning it from onMutate is what threads it into the context parameter of the later callbacks.
Step 3 — Restore the snapshot in onError
onError receives (error, variables, context). Guard against a missing context (an exception thrown inside onMutate before the return leaves context undefined) and restore the snapshot verbatim.
onError(_error, _variables, context) {
// Only restore if onMutate completed and produced a snapshot
if (context?.previous !== undefined) {
queryClient.setQueryData(postKey(id), context.previous);
}
},
Cache Behavior Analysis. Restoring writes the snapshot back as the query’s data without triggering a network request. Any component subscribed to postKey(id) re-renders with the reverted value on the next microtask. Because you cancelled the in-flight query in Step 2, there is no pending fetch left to resolve and re-corrupt the cache after this restore.
Step 4 — Reconcile with the server in onSettled
onSettled runs after both success and error. Invalidate the key so TanStack Query refetches the authoritative server state, correcting any drift between your optimistic guess and what the server actually persisted.
onSettled() {
// Runs on success AND error — reconcile cache with the server of record
queryClient.invalidateQueries({ queryKey: postKey(id) });
},
});
}
Cache Behavior Analysis. invalidateQueries marks the entry stale and triggers a refetch for any active observer. On success this replaces the optimistic guess (e.g. likeCount + 1) with the server’s real count, which may differ if another user liked the post concurrently. On error it fetches fresh data on top of the just-restored snapshot, so even a partial server write is reconciled. This is why onError restore and onSettled invalidation are complementary rather than redundant.
- Always
await cancelQueries— an un-awaited call does not block the in-flight refetch and reintroduces the overwrite race. - Take the snapshot with
getQueryDataafter cancelling, never before, so it reflects the true pre-mutation baseline. - Use functional
setQueryDataupdaters, not a captured value, so concurrent mutations compose instead of clobbering. - Keep
onSettledinvalidation even whenonErrorrestores; setstaleTimeon the query so the reconciling refetch is skipped when data is still fresh.
Edge Cases and Gotchas
Concurrent mutations on the same key
If two toggleLike calls fire in quick succession, each onMutate snapshots at its own start time. Should the second one fail, its context.previous already contains the first mutation’s optimistic write, so restoring it does not un-apply the first. Rely on the onSettled invalidation to converge, or serialize with a mutation scope:
useMutation({
scope: { id: `post-${id}` }, // mutations sharing a scope id run serially
mutationFn: () => toggleLike(id),
});
List caches versus detail caches
When the same entity appears in a list query (['posts']) and a detail query (['post', id]), a rollback must restore both snapshots. Capture and restore each key explicitly, and return both in the context object; a single-key rollback leaves the list showing the optimistic value while the detail reverts.
Snapshot of undefined
If the query has never been fetched, getQueryData returns undefined. Writing that back in onError deletes the cache entry rather than restoring a value. Guard the restore with context?.previous !== undefined (as in Step 3) so a never-fetched query is left untouched rather than blanked.
Common Pitfalls and Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Optimistic value briefly reverts then reappears mid-flight | A background refetch was in flight and resolved after setQueryData, overwriting it with pre-mutation server data |
await queryClient.cancelQueries({ queryKey }) as the first statement in onMutate before reading or writing the cache |
onError fires but UI does not revert |
onMutate did not return the snapshot, so context is undefined and the restore is skipped |
Return { previous } from onMutate and restore with context?.previous guarded against undefined |
| Cache stays wrong after a successful mutation | No onSettled invalidation; the optimistic guess diverged from the server’s actual persisted value |
Add invalidateQueries({ queryKey }) in onSettled so the authoritative server state reconciles after both success and error |
| Rapid double-clicks roll back to a partially-optimistic state | Concurrent mutations each snapshot at their own start, so a later snapshot embeds an earlier optimistic write | Set a shared mutation scope.id to serialize, or lean on onSettled invalidation to converge to server truth |
Frequently Asked Questions
Why does cancelQueries have to run before setQueryData in onMutate?
A background refetch that is already in flight when the mutation starts will resolve after your optimistic setQueryData write and overwrite it with server data that predates the mutation — the user sees the value flip to optimistic, then snap back to old, then to optimistic again. Awaiting cancelQueries aborts those requests so your setQueryData is the last write to touch the cache until the mutation itself resolves. Skipping the await reintroduces the race because the cancellation has not completed before you write.
What happens if two optimistic mutations run concurrently on the same key?
Each onMutate snapshots the cache at its own start time. If the second mutation fails, its context.previous already contains the first mutation’s optimistic write, so restoring that snapshot does not undo the first change. Prefer functional setQueryData updaters so the writes compose, rely on the onSettled invalidation to converge on server truth, or give the mutations a shared scope.id so TanStack Query runs them serially and each snapshot reflects the previous mutation’s committed state.
Do I still need onSettled invalidation if onError already restores the snapshot?
Yes. onError only reverts to the pre-mutation state, which is not the same as the authoritative post-mutation server state. onSettled runs after both success and error and invalidates the key so the cache reconciles with the server — correcting a partial write, a server-side transformation, or concurrent changes from another client. On success it replaces your optimistic guess with the real value; on error it fetches fresh data on top of the restored snapshot. The two mechanisms cover different failure surfaces.
Related
- Mutation Sync & Rollback — the parent reference covering snapshot strategies, atomic error boundaries, and rollback semantics across TanStack Query, SWR, Apollo, and RTK Query.
- Syncing Offline Mutations on Reconnect — the companion pattern for failures caused by lost connectivity, where the mutation is paused and replayed rather than rolled back.
- Cache Invalidation & Server Synchronization — the foundational pillar covering invalidation timing, refetch strategies, and the server-synchronization guarantees that rollback depends on.
- Background Refetch Strategies — how
staleTimeand background refetch interact with theonSettledinvalidation that reconciles the cache after a mutation settles.