skip to content

In TanStack Query v5, in what order do useMutation's lifecycle callbacks run, and how do callbacks passed to mutate() differ from those passed to useMutation?

level: middleimportance: must knowfreq 60%

answer

  1. one callback before the request
  2. success or error, then always
  3. promises are awaited
  4. call-site callbacks come last
  5. unmounted means skipped

basics

~20 s

onMutate runs before the mutationFn, then onSuccess or onError, then onSettled; returned promises are awaited. Callbacks passed to mutate() run after those, only while the component is mounted, and only for the latest mutate call.

solid answer

~40 s

The sequence is: `onMutate(variables, context)` - awaited before the request, and its return value is handed on - then the `mutationFn`, then `onSuccess(data, variables, onMutateResult, context)` or `onError(error, ...)`, then `onSettled(data, error, ...)`. If one of these `useMutation` callbacks returns a promise, the next step waits for it, and the mutation stays `isPending` until `onSettled` finishes. Only then is the status set to `success` or `error`. Callbacks passed as the second argument to `mutate()` fire after that, are not awaited, and have two limits: they are skipped if the component unmounted before the mutation finished, and if `mutate` is called several times they fire only for the last call. So cache work - rollback, invalidation - belongs in `useMutation`; UI-only reactions like closing a dialog or navigating belong in `mutate()`.

go deeper

for a junior

Recall the order onMutate, mutationFn, onSuccess or onError, onSettled, and that mutate() can take its own callbacks.

for a middle

Explain which callbacks are awaited, when the status changes, and the two limits on mutate() callbacks: mounted only, latest call only.

for a senior

Place logic by guarantee: cache work in hook-level callbacks, call-site UI in mutate() callbacks, and review code for rollbacks that can be skipped.

for a principal

Standardise shared mutation hooks that own cache effects, so feature teams only add call-site reactions and cannot forget invalidation or rollback.

## The lifecycle, step by step For one call to `mutate(variables)` or `mutateAsync(variables)`, TanStack Query v5 runs: 1. **status becomes `pending`**, with `variables` recorded; 2. **`onMutate(variables, context)`** - awaited; whatever it returns is stored and passed on as `onMutateResult`; 3. **`mutationFn(variables, context)`** - the request, subject to `retry` (default `0` for mutations) and `networkMode`; 4. on success, **`onSuccess(data, variables, onMutateResult, context)`**; on failure, **`onError(error, variables, onMutateResult, context)`**; 5. **`onSettled(data, error, variables, onMutateResult, context)`** - always; 6. **status becomes `success` or `error`**; 7. the **per-call callbacks** given to `mutate()` fire: `onSuccess` or `onError`, then `onSettled`. The `context` argument is an object holding the `client` (the `QueryClient`), the mutation's `meta` and its `mutationKey`, so callbacks can reach the cache without closing over `useQueryClient()`. ## Awaiting: why step 6 comes after the callbacks The source awaits every `useMutation`-level callback. A promise returned from `onSuccess` delays `onSettled`; a promise returned from `onSettled` delays the status change. That is deliberate: returning an invalidation promise keeps `isPending` true until related queries have refetched, so the button does not re-enable before the list has been refreshed. On the error path, each callback is called inside its own `try`, so a callback that throws cannot replace the original error: the mutation still ends with the `mutationFn`'s error. If a `MutationCache` was created with its own global `onMutate`/`onSuccess`/`onError`/`onSettled`, each of those runs **before** the matching `useMutation` callback at the same stage. ## Hook-level versus call-site callbacks | | on `useMutation({...})` | on `mutate(vars, {...})` | |---|---|---| | callbacks available | `onMutate`, `onSuccess`, `onError`, `onSettled` | `onSuccess`, `onError`, `onSettled` | | order | first | after the hook-level ones and after the status change | | awaited | yes | no | | fires if the component unmounted | yes | **no** | | fires for every call | yes | **only the latest call** from this hook | The two limits come from how the observer works. Each `mutate` call replaces the stored per-call callbacks and moves the observer to the new mutation, so earlier calls' callbacks are forgotten. And the per-call callbacks run only while the observer still has a subscriber - a mounted component. ```tsx import { useMutation } from '@tanstack/react-query' const archive = useMutation({ mutationFn: archiveProject, onSuccess: (_data, _vars, _onMutateResult, context) => context.client.invalidateQueries({ queryKey: ['projects'] }), }) archive.mutate(projectId, { onSuccess: () => closeDialog(), }) ``` Here the invalidation always happens, even if the dialog component unmounts mid-request; `closeDialog` is UI that only makes sense while the dialog exists. ## The practical rule - **Logic that must run** - cache writes, rollbacks, invalidations, logging - goes in `useMutation` (or in a shared custom hook that wraps it). - **Reactions tied to one call site** - toast, navigation, closing a modal, resetting a form - go in `mutate(vars, {...})`. ## A consecutive-calls example ```tsx ['a', 'b', 'c'].forEach((name) => create.mutate(name, { onSuccess: () => console.log('done', name) }), ) ``` If `create` has a hook-level `onSuccess`, it runs three times. The per-call `onSuccess` logs only `done c` - for the last call - whichever request finishes first. To react to each call, use `mutateAsync` and handle each promise. ## A throwing callback on the success path The error path wraps each callback so it cannot mask the original error, but the success path is different: `onSuccess` and `onSettled` run inside the same `try` as the request. If the hook-level `onSuccess` throws - or returns a promise that rejects - the mutation is treated as failed: `onError` runs with that thrown error and the final status is `error`, even though the server accepted the write. Keep success callbacks defensive, or the UI will report a failure for a write that succeeded. ## Mistakes interviewers look for - Rolling back an optimistic update in a `mutate()` callback: a user who navigates away mid-request leaves the cache wrong. - Expecting `isPending` to become false before `onSuccess` finishes. - Assuming a throwing `onError` changes the error the mutation reports.

  • Why put an optimistic rollback in useMutation's onError rather than in mutate()'s onError?
    The call-site `onError` is skipped when the component has unmounted before the mutation settles. If the user closes the view mid-request, the rollback would never run and the cache would keep the optimistic value. The hook-level `onError` always runs.
  • What happens if onSettled returns a promise that takes two seconds?
    The mutation stays `isPending` for those two seconds; its status changes to `success` or `error` only after `onSettled` resolves. The per-call callbacks passed to `mutate()` fire after that as well.

saying these in an interview costs you the question

  • Believes mutate() callbacks run before the useMutation callbacks.
  • Expects mutate() callbacks to fire after the component unmounts.
  • Thinks onSettled runs before onSuccess or onError.
  • Puts cache rollback in the per-call onError of mutate().
  • Assumes isPending turns false before an awaited onSuccess resolves.