skip to content

Using TanStack Query v5, how would you optimistically toggle a todo's done flag so the change rolls back if the server answers with a 500?

level: seniorimportance: must knowfreq 58%

answer

  1. work happens before the request
  2. stop in-flight reads first
  3. snapshot, write, return
  4. restore from the onMutate result
  5. settle against the server

basics

~20 s

In onMutate, cancel in-flight fetches for the list, snapshot it with getQueryData, write the flipped flag with setQueryData and return the snapshot. In onError, write the snapshot back. In onSettled, invalidate the list so it matches the server.

solid answer

~40 s

All of it lives in `useMutation`. `onMutate` runs before the request: first `await queryClient.cancelQueries({ queryKey })` so a background refetch that started earlier cannot land afterwards and overwrite the optimistic value; then read `previous = getQueryData(queryKey)`; then `setQueryData` with an immutable update that flips `done` for that id; and return `{ previous }`. When the server answers 500 the `mutationFn` rejects - mutations do not retry by default - and `onError` receives that returned object as its third argument (`onMutateResult` in current v5 docs), so it writes `previous` back. `onSettled` returns `invalidateQueries({ queryKey })` so, success or failure, the list ends up matching the server. Keep the rollback in the hook-level `onError`: a `mutate()` callback would be skipped if the component unmounted mid-request.

go deeper

for a junior

Recall the shape: change the cache in onMutate, return the old value, put it back in onError, refetch in onSettled.

for a middle

Explain each step's reason - cancel, snapshot, immutable write, return - and how the returned value reaches onError as its third argument.

for a senior

Show the failure modes you have seen: overwritten optimistic values without cancelQueries, rollbacks lost to unmounted call-site callbacks, and entities cached under several keys.

for a principal

Decide which interactions deserve optimistic cache writes, given the rollback code each one adds, and when rendering pending variables is enough.

## The goal The user clicks a checkbox; the todo should show as done **immediately**, before `PATCH /todos/:id` answers. If the server answers with a 500, the checkbox must return to its previous state. In TanStack Query v5 this is done with the cache: write the expected result into the query cache first, keep a snapshot, and restore it on failure. ## The four moves ```tsx import { useMutation, useQueryClient } from '@tanstack/react-query' type Todo = { id: string; title: string; done: boolean } const listKey = ['todos', 'list'] as const export function useToggleTodo() { const queryClient = useQueryClient() return useMutation({ mutationFn: (todo: Todo) => patchTodo(todo.id, { done: !todo.done }), onMutate: async (todo) => { await queryClient.cancelQueries({ queryKey: listKey }) const previous = queryClient.getQueryData<Todo[]>(listKey) queryClient.setQueryData<Todo[]>(listKey, (old) => old?.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t)), ) return { previous } }, onError: (_error, _todo, onMutateResult) => { if (onMutateResult?.previous) { queryClient.setQueryData(listKey, onMutateResult.previous) } }, onSettled: () => queryClient.invalidateQueries({ queryKey: listKey }), }) } ``` 1. **Cancel** - `cancelQueries` stops any fetch of the list that is in flight. Without it, a background refetch that started before the click could resolve after the optimistic write and replace it with the old server state; the checkbox would flip back while the request is still pending. It is `await`ed so the cancellation (and its revert of the cancelled fetch's state) completes before the snapshot. 2. **Snapshot** - `getQueryData` reads the current list. Because every update is immutable, this reference stays a faithful copy of "before". 3. **Write** - `setQueryData` with a function that returns a **new** array and a **new** todo object; mutating `old` in place would also change the snapshot. 4. **Return** - the object returned from `onMutate` is stored with the mutation and handed to `onSuccess`, `onError` and `onSettled` as their third argument. ## What happens on a 500 | Step | What runs | Cache shows | |---|---|---| | click | `onMutate` cancels, snapshots, writes | done: true | | `PATCH` returns 500 | `mutationFn` rejects (mutations default to `retry: 0`) | done: true | | error path | `onError` writes `previous` back | done: false | | settle | `onSettled` invalidates, list refetches in the background | server's value | The final invalidation matters even on failure: the server may have partly applied the change, and on success it guarantees derived fields - an updated timestamp, a server-side reorder - replace the guess. ## Details that decide whether rollback works - **Hook-level, not call-site.** Put `onError` on `useMutation`. Callbacks passed to `mutate()` are skipped if the component unmounts before the request settles, which would leave the optimistic value in place forever. - **Guard the result.** If `onMutate` itself throws, the third argument of `onError` is `undefined`; the `?.` guard avoids a second error in the error handler. - **Every cache that shows the flag.** If the same todo also lives in `['todos', 'detail', id]` or in filtered lists, either snapshot and write each one (`getQueriesData` / `setQueriesData` work across a prefix), or keep the optimistic write to the view the user is looking at and let the settle-time invalidation fix the rest. - **Use `context.client` if you prefer.** In current v5 every callback receives a fourth argument whose `client` is the `QueryClient`, so a shared hook does not need `useQueryClient()`. ## Testing the rollback A focused test proves each step: - mock the `PATCH` to answer 500 after a short delay; - click the checkbox and assert it is checked **immediately**, before the response; - after the response, assert it is unchecked again; - assert the list endpoint was requested again, which shows the settle-time invalidation ran. Also run the test with a list refetch still in flight at click time; that is the case `cancelQueries` exists for. ## When not to do this The cache approach is worth it when several places on screen must show the new state. If only one component shows it, rendering from the mutation's `variables` while `isPending` needs no snapshot and no rollback, and is simpler. ## Naming across versions The docs of current v5 releases call the third callback argument `onMutateResult`; earlier v5 docs, most blog posts and many codebases call the same argument `context`. The behaviour is identical. In current v5 the name `context` refers to the new fourth argument holding `client`, `meta` and `mutationKey`.

  • Why await cancelQueries before taking the snapshot?
    Cancelling a fetch that already has data reverts that query's state to what it was before the fetch started. Awaiting it means the snapshot you take afterwards reflects the settled cache, and no in-flight response can land after your optimistic write and overwrite it.
  • What does onError receive as its third argument if onMutate itself throws?
    `undefined`, because `onMutate` never returned a value. That is why the rollback guards with `onMutateResult?.previous`; without the guard the error handler would throw a second, misleading error.
  • Would you still invalidate in onSettled after a successful toggle?
    Usually yes: the server may change more than the flag, such as an updated timestamp or a completed-count elsewhere. If the `PATCH` response contains the full todo, writing it with `setQueryData` in `onSuccess` is a cheaper alternative for that entry.

It is like moving a chess piece while your opponent thinks, with a photo of the board taken first: if the move turns out illegal, you restore the board from the photo rather than trying to remember where everything was.

saying these in an interview costs you the question

  • Skips cancelQueries and cannot explain why the checkbox sometimes flips back mid-request.
  • Flips done by assigning into the cached todo object instead of returning new objects.
  • Takes the snapshot after writing the optimistic value.
  • Puts the rollback in the onError passed to mutate().
  • Assumes the mutation retries the 500 three times before onError runs.