Data Fetching with TanStack
Data Fetching with TanStack

Optimistic updates and their races

Full · ~40 min
After this unit you can
  • Write the cache recipe: onMutate cancels, snapshots, writes and returns the snapshot; onError rolls back; onSettled invalidates
  • Predict what a refetch already in flight does to an optimistic write, with and without cancelQueries
  • Choose the v5 UI-only recipe (variables while pending, useMutationState elsewhere) 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

Simple
1 / 2

Show the result before the server confirms it — two ways

Flag

An 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 variablesVia the cache
Shows the changewhere you render iteverywhere that key is read
On failurenothing to undoroll back the snapshot
Codea few linesthe 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.

Medium

An optimistic toggle, moment by moment

Flag

The 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.)

MomentBoxMutation status
click → onMutateticked (written into the cache)pending
PATCH in flighttickedpending
success: onSettled refetch runningtickedpending (the returned promise)
success: refetch doneticked — now the server's valuesuccess
failure: onErrorun-ticked at once (snapshot restored)pending
failure: onSettled refetch runningun-tickedpending
failure: refetch doneun-ticked — the server's valueerror

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.

Complex

The races — and what guards each one

Flag

An 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'] })?

Flag

A teammate takes the snapshot after the optimistic setQueryData. What breaks?

Flag

How does onError get hold of the snapshot taken in onMutate?

Flag

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?

Flag

With the variables recipe, the POST fails. What happens to the faded item, and what can you still do?

Flag

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?

Flag

To avoid a re-sync landing mid-sequence, onSettled invalidates only when queryClient.isMutating({ mutationKey: ['todos', 'toggle'] }) === 1. Why 1 and not 0?

Flag

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?

Flag

Fill in the blank

Write onMutate for adding a todo: stop outgoing refetches, snapshot, write optimistically, and hand the snapshot on.

Flag
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.

Flag
onError: (_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.

Flag
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);
Flag
// 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"])));
Flag
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.
Flag

Challenge

Finish the optimistic toggle: cancel, roll back, re-sync

Flag

useToggleTodo 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

Loading the in-browser editor…

Further reading