Make optimistic updates reversible, and decide them once
When a mutation updates the UI before the server responds, cancel in-flight queries for the affected keys, snapshot their data, apply the update, roll back on error, and invalidate when the mutation settles.
Decide in onMutate whether the optimistic change applies, and read that decision in later callbacks instead of recomputing it.
Implementation
- Choose the approach by where the change appears:
- Only in the component that triggers the mutation: render the pending
variablesfromuseMutationwhileisPending, without touching the cache. - In several places: update the cache in
onMutate.
- Only in the component that triggers the mutation: render the pending
- For cache updates, in
onMutate:- Await
cancelQueriesfor the affected keys, so an in-flight fetch cannot overwrite the optimistic value. - Snapshot the current data with
getQueryData. - Write the optimistic value with
setQueryData, handling the case where no data is cached yet. - Return the decision, the snapshot, and any derived values.
- Await
- In
onError, restore the snapshot when the update was applied. - In
onSettled, invalidate the affected keys and return the promise, so the cache matches the server whether the mutation succeeded or failed. - In TanStack Query v5, the value returned from
onMutatereaches later callbacks asonMutateResult: the third argument ofonErrorandonSuccess, and the fourth ofonSettled. The final argument is a separateMutationFunctionContext. - Return a discriminated result, such as
{ applied: false } | { applied: true; previous: Todo | undefined }, and branch on it in later callbacks. onMutatecannot cancel the mutation; the request still runs. To prevent it, check before callingmutateor insidemutationFn.- Skip optimistic updates when the server's result is hard to predict, such as when it assigns values the client cannot know.
Rationale
An optimistic update shows a result the server has not confirmed. A refetch already in flight can resolve after the optimistic write and overwrite it, so the change seems to disappear. If the server rejects the change, the UI must return to the real state. Deriving the "does this apply?" decision separately in each callback lets rollback and invalidation disagree with the original write.
Examples
Application: A cache update used in several places
Incorrect (counterexample):
const toggleTodo = useMutation({
mutationFn: toggleTodoComplete,
onMutate: (todoId: number) => {
queryClient.setQueryData<Array<Todo>>(['todos'], (old) =>
old?.map((todo) => (todo.id === todoId ? { ...todo, completed: !todo.completed } : todo)),
);
},
});
A refetch in flight can overwrite the toggle, a failure leaves the wrong state on screen, and nothing resynchronizes with the server.
Correct:
const toggleTodo = useMutation({
mutationFn: toggleTodoComplete,
onMutate: async (todoId: number) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData<Array<Todo>>(['todos']);
queryClient.setQueryData<Array<Todo>>(['todos'], (old) =>
old?.map((todo) => (todo.id === todoId ? { ...todo, completed: !todo.completed } : todo)),
);
return { previous };
},
onError: (_error, _todoId, onMutateResult) => {
queryClient.setQueryData(['todos'], onMutateResult?.previous);
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
});
Application: A decision shared by several callbacks
Incorrect (counterexample):
onMutate: async ({ id, patch }) => {
const title = normalizedTitle(patch);
if (!title) return;
// ...apply the optimistic title and return the snapshot
},
onError: (_error, { patch }, onMutateResult) => {
if (!normalizedTitle(patch) || !onMutateResult) return;
queryClient.setQueryData(todoKey, onMutateResult.previous);
},
onSettled: (_data, _error, { patch }) => {
if (!normalizedTitle(patch)) return;
return queryClient.invalidateQueries({ queryKey: todoKey });
},
The same decision is recomputed in three places, and any difference between the copies makes rollback or invalidation disagree with the write.
Correct:
type RenameResult = { applied: false } | { applied: true; previous: Todo | undefined };
onMutate: async ({ patch }): Promise<RenameResult> => {
const title = normalizedTitle(patch);
if (!title) return { applied: false };
await queryClient.cancelQueries({ queryKey: todoKey });
const previous = queryClient.getQueryData<Todo>(todoKey);
queryClient.setQueryData<Todo>(todoKey, (todo) => (todo ? { ...todo, title } : todo));
return { applied: true, previous };
},
onError: (_error, _variables, onMutateResult) => {
if (onMutateResult?.applied) queryClient.setQueryData(todoKey, onMutateResult.previous);
},
onSettled: (_data, _error, _variables, onMutateResult) => {
if (onMutateResult?.applied) return queryClient.invalidateQueries({ queryKey: todoKey });
},
todoKey is defined once in the hook body, and later callbacks read the decision from onMutateResult.
Validation
Make the mutation fail, such as by blocking the request in the browser, and check that the UI returns to the previous state.
Trigger a refetch while the mutation is pending and check that the optimistic value is not overwritten.
Check that later callbacks read onMutateResult rather than re-deriving decisions from the variables.
A mutation without an optimistic update is not a violation of this rule.