Optimistic updates and their races
- Write the cache recipe:
onMutatecancels, snapshots, writes and returns the snapshot;onErrorrolls back;onSettledinvalidates - Predict what a refetch already in flight does to an optimistic write, with and without
cancelQueries - Choose the v5 UI-only recipe (
variableswhile pending,useMutationStateelsewhere) over a cache write when one place shows the change - Spot the races between overlapping mutations — a whole-list rollback undoing a newer change, a mid-sequence re-sync — and guard them with a per-item rollback and
isMutating
Read
Show the result before the server confirms it — two ways
FlagAn optimistic update shows what the write will probably produce while the request is still running, then reconciles with what the server actually says. TanStack Query v5 documents two ways to do it, and the choice is about how many places must show the change.
1. In the UI, from variables (new in v5). Leave the cache alone. While the mutation is
pending, render what you're sending:
const addTodo = useMutation({
mutationFn: (title: string) => api.createTodo(title),
onSettled: () => queryClient.invalidateQueries({ queryKey: ["todos"] }), // returned: stay pending
});
<ul>
{todos.map((todo) => <li key={todo.id}>{todo.title}</li>)}
{addTodo.isPending && <li style={{ opacity: 0.5 }}>{addTodo.variables}</li>}
</ul>
The faded item disappears when the mutation settles; by then the refetch has put the real one in
the list. If the save fails, it disappears too — no rollback needed, because nothing was
written. (variables survives an error, so you can render "Couldn't save — Retry" instead.)
2. In the cache, from onMutate. Write the expected result into the cached data, so every
component reading that key shows it — the list, a counter in the header, a detail pane. The
price is that you changed shared state, so you must undo it if the server says no:
useMutation({
mutationFn: toggleTodo,
onMutate: async (vars) => { /* cancel, snapshot, write, return the snapshot */ },
onError: (err, vars, snapshot) => { /* put the snapshot back */ },
onSettled: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
});
Via variables | Via the cache | |
|---|---|---|
| Shows the change | where you render it | everywhere that key is read |
| On failure | nothing to undo | roll back the snapshot |
| Code | a few lines | the full recipe (next example) |
The docs' advice: if one place shows the optimistic result, use variables; if several do,
write the cache. Either way, invalidate when it settles — the server's answer replaces your
guess.
An optimistic toggle, moment by moment
FlagThe recipe in a custom hook, then what the user sees at each step — for a save that succeeds and one that fails.
export function useToggleTodo(filters: TodoFilters) {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ["todos", "toggle"],
mutationFn: ({ id, done }: { id: number; done: boolean }) => api.patchTodo(id, done),
onMutate: async ({ id, done }) => {
await queryClient.cancelQueries({ queryKey: todoKeys.lists() });
const previous = queryClient.getQueryData<Todo[]>(todoKeys.list(filters));
queryClient.setQueryData<Todo[]>(todoKeys.list(filters), (old) =>
old?.map((t) => (t.id === id ? { ...t, done } : t)),
);
return { previous };
},
onError: (_error, _vars, onMutateResult) => {
queryClient.setQueryData(todoKeys.list(filters), onMutateResult?.previous);
},
onSettled: () => queryClient.invalidateQueries({ queryKey: todoKeys.lists() }),
});
}
(filters is the list the user is looking at.)
| Moment | Box | Mutation status |
|---|---|---|
click → onMutate | ticked (written into the cache) | pending |
| PATCH in flight | ticked | pending |
success: onSettled refetch running | ticked | pending (the returned promise) |
| success: refetch done | ticked — now the server's value | success |
failure: onError | un-ticked at once (snapshot restored) | pending |
failure: onSettled refetch running | un-ticked | pending |
| failure: refetch done | un-ticked — the server's value | error |
Two rows surprise people. A failed save is rolled back immediately, but the mutation only
becomes error — and isError only turns true — after the returned onSettled refetch
has finished, because the mutation settles after its last callback (observed headlessly against
v5.104). If the error message must appear the instant the save fails, show it from onError
or don't return the refetch. And the success path shows the server's value in the end, not
yours: if the server normalised something, the refetch wins.
Why cancel lists() but write one list? Any list refetch in flight could carry the old
done value, so cancel the family. The snapshot and write target the list on screen; other
cached lists catch up when the invalidation marks them stale.
The UI-only version, across components
When the pending item must show somewhere other than the component that called mutate, read
pending mutations with useMutationState, matched by mutationKey:
const pendingTitles = useMutationState({
filters: { mutationKey: ["addTodo"], status: "pending" },
select: (mutation) => mutation.state.variables as string,
});
// → an array: one entry per add still in flight
It's an array because several mutations can be pending at once. Key the rows by
mutation.state.submittedAt when titles can repeat.
The races — and what guards each one
FlagAn optimistic update is a bet against everything else touching the same cache entry. Three races, each reproduced headlessly against TanStack Query v5.104.
A refetch that was already on its way
A focus refetch reads the server, then the user clicks, then the refetch answers — with the
pre-click value. Without cancelQueries in onMutate, that response overwrites the optimistic
write and the box un-ticks (in the test run, done went back to false the moment the old
refetch landed). With it, the in-flight fetch is cancelled and its late response is ignored,
even when the queryFn doesn't use the abort signal — consuming the signal additionally
aborts the request itself.
Two mutations, one snapshot
The user ticks todo 1, then todo 2, before either PATCH returns. Each onMutate snapshots the
whole list:
toggle 1: snapshot [1:false, 2:false] → write [1:true, 2:false]
toggle 2: snapshot [1:true, 2:false] → write [1:true, 2:true ]
toggle 1 fails → restore its snapshot → [1:false, 2:false] ← todo 2's tick is gone too
Rolling back a whole-list snapshot undoes every newer optimistic change. Todo 2 un-ticks although its save is still running (and may succeed). The fix is a narrower rollback: undo only this mutation's change.
onError: (_error, { id }, onMutateResult) => {
const previousTodo = onMutateResult?.previousTodo;
if (!previousTodo) return; // onMutate threw, or found no row: nothing of ours to undo
queryClient.setQueryData<Todo[]>(key, (old) =>
old?.map((t) => (t.id === id ? previousTodo : t)),
);
},
…with onMutate returning { previousTodo } — just the row it changed. The guard matters:
onError also runs when onMutate itself throws, and then its third argument is undefined.
One mutation's re-sync lands mid-sequence
Toggle 1 settles and invalidates while toggle 2's PATCH is still in flight. That refetch
returns server data without toggle 2, so todo 2 flickers off, then back on when its own
onSettled refetches — measured: 2:✓ → 2:· → 2:✓. Invalidate only when this is the last
mutation of its kind still running, and the flicker is gone:
onSettled: () => {
if (queryClient.isMutating({ mutationKey: ["todos", "toggle"] }) === 1) {
return queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
}
},
The === 1 is deliberate: inside onSettled, the mutation running it still counts as pending.
Measured: with two toggles in flight, the first onSettled saw isMutating() = 2, and the last
saw 1. This needs a mutationKey to filter on.
Which recipe dodges which race
The variables recipe writes nothing to the cache, so it has no stale snapshots to restore and
nothing for a refetch to overwrite; overlapping saves just render several pending rows
(useMutationState returns all of them). It still re-syncs on settle. That's a reason to prefer
it whenever only one place needs to show the pending change.
Quiz
Why does the cache recipe start onMutate with await queryClient.cancelQueries({ queryKey: ['todos'] })?
A teammate takes the snapshot after the optimistic setQueryData. What breaks?
How does onError get hold of the snapshot taken in onMutate?
Posting a comment should show it, faded, at the bottom of the comment list under the post. Nothing else on the page reflects comments. Which approach fits best?
FlagWith the variables recipe, the POST fails. What happens to the faded item, and what can you still do?
Each toggle snapshots the whole list in onMutate and restores it in onError. The user ticks todo 1, then todo 2; both PATCHes are in flight. Toggle 1 fails. What does the list show?
To avoid a re-sync landing mid-sequence, onSettled invalidates only when queryClient.isMutating({ mutationKey: ['todos', 'toggle'] }) === 1. Why 1 and not 0?
The recipe's onSettled returns the invalidation. A save fails while the list refetch takes 2 seconds. When does the box un-tick, and when does isError become true?
Fill in the blank
Write onMutate for adding a todo: stop outgoing refetches, snapshot, write optimistically, and hand the snapshot on.
onMutate: async (newTodo: Todo) => { await queryClient.({ queryKey: ["todos"] }); const previous = queryClient.<Todo[]>(["todos"]); queryClient.setQueryData<Todo[]>(["todos"], (old) => [...(old ?? []), newTodo]); { previous }; },
Roll back on failure, and re-sync with the server after success or failure.
FlagonError: (_error, _newTodo, onMutateResult) => { queryClient.setQueryData(["todos"], onMutateResult?.); }, : () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
The v5 UI-only recipe: show the todo being added, faded, while its save runs.
Flag{addTodo. && <li style={{ opacity: 0.5 }}>{addTodo.}</li>}
Show pending adds in a component that didn't call mutate.
const pendingTitles = ({ filters: { mutationKey: ["addTodo"], status: "pending" }, select: (mutation) => mutation.state.variables as string, });
Predict the output
// ["todos"] holds [{ id: 1, done: false }].
queryClient.invalidateQueries({ queryKey: ["todos"] }); // GET starts: server says done: false
toggle.mutate(1); // onMutate WITHOUT cancelQueries:
// setQueryData(["todos"], (old) => old.map((t) => ({ ...t, done: true })))
// The PATCH is still in flight. Now the GET answers with done: false.
console.log(queryClient.getQueryData(["todos"])[0].done);// Cache: [{ id: 1, done: false }, { id: 2, done: false }]
// onMutate snapshots the WHOLE list, then ticks one row; onError restores the snapshot.
toggle.mutate(1); // PATCH 1 in flight
toggle.mutate(2); // PATCH 2 in flight
// PATCH 1 now fails; PATCH 2 is still running.
console.log(JSON.stringify(queryClient.getQueryData(["todos"])));const toggle = useMutation({
mutationKey: ["todos", "toggle"],
mutationFn: patchTodo,
onSettled: (_data, _error, id) =>
console.log(id, queryClient.isMutating({ mutationKey: ["todos", "toggle"] })),
});
toggle.mutate(1);
toggle.mutate(2);
// PATCH 1 settles first, then PATCH 2.Challenge
Finish the optimistic toggle: cancel, roll back, re-sync
FlaguseToggleTodo ticks a checkbox optimistically: onMutate writes the new done value into the
cached list before the PATCH is sent. That part works. The rest of the recipe is missing:
- If a background refetch was already on its way when the user clicked, it lands a moment later with the old data and the tick disappears — then reappears, or not.
- When the server rejects a save, the box stays ticked, although the todo isn't done.
- Nothing re-syncs with the server afterwards, so other users' changes never show up.
Finish useToggleTodo. The component and api.ts are fine as they are.
The tests render TodoChecklist with their own QueryClient and a fake server that can hold
requests and reject a save. Like a real server, it reads its rows when a GET arrives — so a GET
sent before your write answers with pre-write data.
Your task
Edit TodoChecklist.tsx so the tests pass:
- Clicking a box ticks it at once, before the server answers
- A refetch that was already in flight when the user clicked doesn't undo the tick when it lands
- A failed save un-ticks the box at once (without waiting for a refetch) and shows the error
- After a save settles — succeeded or failed — the list is refetched, so other changes on the server show up