In TanStack Query v5, what is the difference between the mutate and mutateAsync functions that useMutation returns?
answer
- one returns nothing, one returns a promise
- who handles the rejection
- callbacks versus await
- composing several writes
basics
~20 smutate starts the mutation and returns nothing; errors are caught internally and surface through callbacks and the hook's error state. mutateAsync returns a promise that resolves with the data or rejects with the error, so you must catch it yourself.
solid answer
~40 sBoth start the same mutation and both run the callbacks defined on `useMutation`. `mutate(variables, callbacks?)` returns `void`: internally the promise is caught with a no-op, so a failure never becomes an unhandled rejection - you react to it through `onError`/`onSettled` or by rendering `isError` and `error`. `mutateAsync(variables, callbacks?)` returns a `Promise` that resolves with the `mutationFn`'s data or **rejects** with its error; that rejection is yours to handle with `try/catch`, or it is reported as unhandled. Use `mutate` for the usual fire-and-render case, and `mutateAsync` when the call site needs to compose: await the result and then navigate, run several writes with `Promise.all` or `Promise.allSettled`, or chain a second request on the first one's data.
go deeper
Recall that mutate returns nothing and handles errors internally, while mutateAsync returns a promise you must catch.
Explain how mutate wraps the same call with a no-op catch, which callbacks run for both, and when composition needs mutateAsync.
Spot unhandled rejections from mutateAsync in production logs and choose Promise.allSettled when several writes may fail independently.
Set a convention for mutation hooks so teams default to mutate plus callbacks, reserving mutateAsync for composed flows with explicit error handling.
## Two ways to start the same mutation `useMutation` in TanStack Query v5 returns a result object with, among other things, two functions that trigger the write: | | `mutate` | `mutateAsync` | |---|---|---| | return value | `void` | `Promise<TData>` | | on error | caught internally (a no-op `catch`) | the promise **rejects** | | hook-level callbacks (`onSuccess`, `onError`, `onSettled`) | run | run | | per-call callbacks as second argument | accepted | accepted | | `isPending`, `isError`, `error`, `data` on the result | updated | updated | The hook source makes the relationship explicit: `mutateAsync` is the observer's own `mutate`, which returns the mutation's promise, and `mutate` wraps that same call and attaches `.catch(noop)`. ## `mutate`: fire and render `mutate` is designed for event handlers that do not need a return value: ```tsx import { useMutation } from '@tanstack/react-query' function RenameButton({ id }: { id: string }) { const rename = useMutation({ mutationFn: renameProject }) return ( <> <button disabled={rename.isPending} onClick={() => rename.mutate({ id, name: 'Q3 plan' })} > Rename </button> {rename.isError && <p role="alert">{rename.error.message}</p>} </> ) } ``` Because the error is swallowed after being recorded, there is no `try/catch` and no risk of an unhandled rejection. The failure is visible through the hook state and through `onError`. ## `mutateAsync`: when the call site composes `mutateAsync` is for code that needs the outcome in sequence: - **await, then act** - navigate to the created entity with the id from the response; - **several writes** - `Promise.all` to fail fast, or `Promise.allSettled` to learn which of several writes failed; - **chaining** - use the first response as input to a second request. ```tsx async function onSubmit(values: ProjectInput) { try { const project = await createProject.mutateAsync(values) navigate(`/projects/${project.id}`) } catch { // the hook's error state and onError already reflect the failure } } ``` The `catch` is not optional. Without it, a rejected write becomes an unhandled promise rejection, which error-tracking tools report as a crash even though the UI handled the error. ## What does not differ Some things are the same for both, and candidates sometimes assume otherwise: 1. **The mutation runs identically** - same `mutationFn`, same retries (mutations default to `retry: 0`), same `networkMode`. 2. **Hook-level callbacks always run** - `onMutate`, `onSuccess`/`onError`, `onSettled` fire for both functions. 3. **Both functions are stable references** across renders - `mutate` is memoized and `mutateAsync` is bound to the observer - but the result object that holds them is a new object on each render, so depend on `mutation.mutate`, never on `mutation`, in effect dependency arrays. ## Choosing between them - Default to **`mutate`** in components; render `isPending` and `error`, and put side effects in callbacks. - Use **`mutateAsync`** when you need the resolved data in the same function, or need to coordinate several writes. - Never call `mutateAsync` and ignore its promise - that is `mutate` with a crash path added. ## Code after `await` versus per-call callbacks Both functions accept per-call `onSuccess`/`onError`/`onSettled` as a second argument, but with `mutateAsync` the code after `await` usually does that job. The two are not equivalent: - code after `await mutateAsync(...)` runs whenever the promise settles, **even if the component has unmounted** in the meantime - navigating or setting state there can act on a screen the user already left; - per-call callbacks are **skipped after unmount** and fire only for the latest call from that hook. Pick the behaviour you want deliberately: "always continue the flow" suits `await`; "only react if this UI is still here" suits the callbacks. ## A bug worth recognising in review A common pattern in code reviews looks harmless: ```tsx <button onClick={async () => { await save.mutateAsync(draft); onClose() }}> Save </button> ``` When the save fails, `onClose` is correctly skipped and the hook shows the error - but the rejected promise escapes the handler, and every failed save is logged as an unhandled rejection. Two equivalent fixes: wrap the body in `try/catch`, or use `save.mutate(draft, { onSuccess: onClose })`, which keeps the "close only on success" behaviour and cannot leak a rejection. ## Status values for reference A mutation result moves through `status: 'idle' | 'pending' | 'success' | 'error'`, with the booleans `isIdle`, `isPending`, `isSuccess`, `isError`. `reset()` returns it to `idle` and clears `error` and `data` - useful when a dialog reopens after a failed submit.
- Why does an uncaught mutateAsync rejection show up in error tracking even though the form displays the error?The hook still records the error in its state, which the form renders. But the promise returned by `mutateAsync` also rejects, and if nothing awaits it inside `try/catch` the runtime reports an unhandled rejection. `mutate` avoids this because it catches internally.
- Is it safe to put mutation.mutate in a useEffect dependency array?Yes: `mutate` is memoized per hook instance. Depending on the whole `mutation` object is the mistake, because `useMutation` returns a new top-level object on each render, which would re-run the effect every time.
saying these in an interview costs you the question
- Says mutate returns a promise that can be awaited.
- Calls mutateAsync without try/catch because the hook already shows the error.
- Believes the useMutation callbacks run only for mutate and not for mutateAsync.
- Puts the whole mutation result object in effect dependencies.
- Assumes mutations retry three times by default like queries.