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?
answer
- one callback before the request
- success or error, then always
- promises are awaited
- call-site callbacks come last
- unmounted means skipped
basics
~20 sonMutate 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 sThe 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
Recall the order onMutate, mutationFn, onSuccess or onError, onSettled, and that mutate() can take its own callbacks.
Explain which callbacks are awaited, when the status changes, and the two limits on mutate() callbacks: mounted only, latest call only.
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.
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.