Mutations: writes, callbacks and the cache
- Write a custom hook around
useMutationthat owns themutationFnand the cache follow-up, and block a double submit withisPending - Choose
mutateormutateAsync, and handle the rejection thatmutateAsyncthrows - Predict when hook-level and call-level
onSuccess/onError/onSettledrun: their order, after an unmount, and for consecutivemutatecalls - Keep a mutation pending until its invalidation has refetched, by returning the promise from a hook-level callback
Read
A mutation is a write you trigger — and the cache doesn't know about it
FlaguseQuery reads: it runs by itself when a component needs data. useMutation writes: it runs
only when you call mutate, and it has no key, so the query cache has no idea which data
your write just changed. Telling it is your job.
function AddTodo() {
const queryClient = useQueryClient();
const addTodo = useMutation({
mutationFn: (title: string) => api.createTodo(title),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
});
return (
<button disabled={addTodo.isPending} onClick={() => addTodo.mutate("Buy milk")}>
{addTodo.isPending ? "Saving…" : "Add"}
</button>
);
}
A mutation is always in one of four states — in TanStack Query v5:
status | Flag | Meaning |
|---|---|---|
idle | isIdle | not called yet, or reset() |
pending | isPending | mutationFn is running (v4 called this loading / isLoading) |
error | isError | it failed; see error |
success | isSuccess | it worked; see data |
Alongside: variables (what you passed to the latest mutate — kept after an error, handy for
"Retry"), data, error, and reset() to clear it all back to idle.
Three facts that surprise people coming from queries:
- No retries by default. A failed write fails once (
retry: 0). Queries retry 3 times; mutations don't, because repeating a write isn't always safe. Opt in withretrywhen the endpoint is idempotent. - No deduping. Calling
mutatetwice sends two requests — mutations run in parallel. A double-clicked Save button creates two todos unless you disable it whileisPending. - No automatic refresh. Without that
onSuccess, the list keeps showing the old data until something else refetches it. The fix is one of the previous unit's calls: invalidate the affected keys, or write the response withsetQueryData.
Writes usually live in a custom hook (useAddTodo) that owns the mutationFn and the
cache follow-up, so every screen that adds a todo refreshes the same queries.
A save hook and its form, line by line
FlagSplit the work by who must react. The cache must be refreshed after every successful save,
no matter which screen saved — that belongs in the hook. Clearing an input or closing a dialog
belongs to one component — that goes on the mutate call.
export function useAddTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (title: string) => api.createTodo(title),
onSuccess: () => queryClient.invalidateQueries({ queryKey: todoKeys.lists() }),
});
}
export function AddTodoForm() {
const [title, setTitle] = useState("");
const addTodo = useAddTodo();
return (
<form
onSubmit={(e) => {
e.preventDefault();
addTodo.mutate(title.trim(), { onSuccess: () => setTitle("") });
}}
>
<input value={title} onChange={(e) => setTitle(e.target.value)} aria-label="New todo" />
<button type="submit" disabled={addTodo.isPending}>
{addTodo.isPending ? "Saving…" : "Save"}
</button>
{addTodo.isError && <p role="alert">Couldn't save: {addTodo.error.message}</p>}
</form>
);
}
onSuccessreturns the invalidation. A promise returned from a hook-level callback is awaited before the mutation counts as finished, soisPendingstaystrueuntil the list has refetched. WriteonSuccess: () => { queryClient.invalidateQueries(...) }(braces, noreturn) and the button re-enables while the list still shows the old rows. Run headlessly, the difference is one row: with the return, the mutation is stillpendingwhile the list refetches; without it, it's alreadysuccess.todoKeys.lists(), not one list. The hook can't know which filtered lists the new todo lands in; the prefix reaches them all and only the visible ones refetch now.- The call-level
onSuccessclears the input. Clearing straight aftermutate(...)would wipe the user's text even when the save fails. Callbacks passed tomutaterun after the hook's own (so after the refetch, here), and only if this component is still mounted — exactly right for UI that belongs to it. disabled={addTodo.isPending}. Mutations aren't deduplicated: a second click sends a second POST. Disabling while pending is the simplest guard.addTodo.erroris typedErrorby default in v5 (v4 usedunknown), and stays set until the nextmutateorreset().
The callbacks run in this order (checked against v5.104):
success: hook onMutate → hook onSuccess → hook onSettled → mutate's onSuccess → mutate's onSettled
error: hook onMutate → hook onError → hook onSettled → mutate's onError → mutate's onSettled
onSettled runs after success or failure. Invalidating there instead of in onSuccess also
resyncs after a failed write that may have partly applied on the server.
Where mutation callbacks surprise you
FlagThe rule "cache work in the hook, UI work on the mutate call" comes from three behaviours
that the docs state and that were each checked here against TanStack Query v5.104.
Call-level callbacks skip after unmount
function EditDialog({ onClose }) {
const save = useSaveTodo(); // hook-level onSuccess: nothing
return (
<button
onClick={() => {
save.mutate(draft, { onSuccess: () => queryClient.invalidateQueries({ queryKey: todoKeys.all }) });
onClose(); // ❌ the dialog unmounts before the save finishes
}}
>
Save
</button>
);
}
The save succeeds, but the call-level onSuccess never runs — "those additional callbacks
won't run if your component unmounts before the mutation finishes" — so nothing is
invalidated and the list stays stale. Run headlessly: after unmount, the hook's onSuccess
fired and the call's didn't. Anything that must happen after every successful save goes in the
hook.
Consecutive calls: call-level fires once, for the last call
for (const title of ["Todo 1", "Todo 2", "Todo 3"]) {
addTodo.mutate(title, { onSuccess: (todo) => toast(`Added ${todo.title}`) });
}
Three requests go out (mutations run in parallel). The hook's onSuccess runs three times,
in the order the responses arrive. The call's onSuccess runs once, for "Todo 3" — even
when "Todo 1" happens to finish last — because each mutate re-subscribes the hook's observer
to the newest mutation. One toast for three saves is the visible symptom. Per-item feedback
belongs in hook-level callbacks (they get variables), or in one Promise.all over
mutateAsync calls.
Only hook-level promises keep the mutation pending
Return a promise from the hook's onSuccess/onError/onSettled and the mutation stays
pending until it settles. Return one from a mutate(…, { onSuccess }) callback and nothing
waits: by the time call-level callbacks run, the mutation is already success. An invalidation
moved from the hook into the form therefore loses two things — it no longer runs for other
callers, and the button no longer stays disabled until the list is fresh.
Parallel by default — unless you scope them
Mutations aren't deduplicated or queued. Two quick saves of the same record can reach the
server in either order. Give them a scope and they run one after another, later ones starting in
isPaused until their turn:
useMutation({ mutationFn: saveDraft, scope: { id: `draft-${draftId}` } });
Offline writes pause
With the default networkMode: "online", a mutation started offline doesn't fail: it waits
(isPaused) and runs when the connection returns. Show that state — "Will save when you're back
online" — rather than a spinner that never ends.
Quiz
useMutation({ mutationFn: createTodo }) POSTs successfully, but the todo list on the same screen keeps showing the old rows. Why?
A user double-clicks Save, which calls addTodo.mutate(draft). Nothing else guards it. What happens?
<button onClick={() => save.mutateAsync(draft)}> — and the save fails. What happens?
A dialog calls save.mutate(draft, { onSuccess: () => queryClient.invalidateQueries({ queryKey: todoKeys.all }) }) and then immediately onClose(), which unmounts it. The save succeeds. What happens to the list behind the dialog?
for (const t of ['A', 'B', 'C']) addTodo.mutate(t, { onSuccess: (todo) => toast(todo.title) }), where useAddTodo also has a hook-level onSuccess. All three saves succeed. How often does each callback run?
onSuccess: () => { queryClient.invalidateQueries({ queryKey: todoKeys.lists() }); } — the Save button is disabled={isPending}. What does the user see after a successful POST?
A successful addTodo.mutate(t, { onSuccess, onSettled }) on a hook that defines onSuccess and onSettled too. In what order do the four run?
A mutation's endpoint answers 500. With default options, how many times does mutationFn run?
Fill in the blank
The hook refreshes every list after a save, and stays pending until the refetch is done.
Flagexport function useAddTodo() { const queryClient = (); return useMutation({ mutationFn: (title: string) => api.createTodo(title), onSuccess: () => { queryClient.invalidateQueries({ queryKey: todoKeys.lists() }); }, }); }
Clear the input only after a successful save, and block a second submit while one is running.
FlagaddTodo.mutate(title, { : () => setTitle("") }); <button type="submit" disabled={addTodo.}>Save</button>
Create the post, then navigate to it — and handle a failed create yourself.
Flagasync function onPublish() { try { const post = await createPost.(draft); navigate(`/posts/${post.id}`); } { // the error is also on createPost.error } }
Predict the output
const add = useMutation({
mutationFn: createTodo,
onMutate: () => log("hook onMutate"),
onSuccess: () => log("hook onSuccess"),
onSettled: () => log("hook onSettled"),
});
add.mutate("Buy milk", {
onSuccess: () => log("call onSuccess"),
onSettled: () => log("call onSettled"),
});
// createTodo succeeds.const add = useMutation({
mutationFn: (title) => api.createTodo(title), // "Todo 1" is the slowest to answer
onSuccess: (todo) => hookLog.push(todo.title),
});
for (const title of ["Todo 1", "Todo 2", "Todo 3"]) {
add.mutate(title, { onSuccess: (todo) => callLog.push(todo.title) });
}
// All three succeed; Todo 2 answers first, then Todo 3, then Todo 1.const save = useMutation({
mutationFn: saveTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] }); // not returned
},
});
const list = useQuery({ queryKey: ["todos"], queryFn: fetchTodos }); // takes ~500 ms
// After save.mutate(…) succeeds, the list refetch is under way:
console.log(save.status, list.fetchStatus);Challenge
Fix the save button that double-submits and never refreshes
FlaguseAddTodo wraps a useMutation that POSTs a new todo, and AddTodoForm uses it. Support
tickets:
- "I added a todo and it never showed up — until I reloaded."
- "I double-clicked Save and got the todo twice."
- "The save failed and my text was gone."
Fix the hook and the form. TodoList.tsx (the list and its query key) and api.ts are fine as
they are.
The tests render the form and the list with their own QueryClient and a fake server whose
requests they can pause or fail. One test calls useAddTodo from a different component, so the
list refresh has to live in the hook.
Your task
Edit AddTodo.tsx so the tests pass:
- After a save through
useAddTodo, the todo list refetches and shows the new todo — whichever component called the hook - The mutation stays pending (Save disabled) until the refreshed list shows the new todo
- Clicking Save again while a save is running doesn't send a second request
- A failed save keeps what the user typed and shows the error
- A successful save clears the input