Data Fetching with TanStack
Data Fetching with TanStack

Mutations: writes, callbacks and the cache

Full · ~35 min
After this unit you can
  • Write a custom hook around useMutation that owns the mutationFn and the cache follow-up, and block a double submit with isPending
  • Choose mutate or mutateAsync, and handle the rejection that mutateAsync throws
  • Predict when hook-level and call-level onSuccess/onError/onSettled run: their order, after an unmount, and for consecutive mutate calls
  • Keep a mutation pending until its invalidation has refetched, by returning the promise from a hook-level callback

Read

Simple
1 / 2

A mutation is a write you trigger — and the cache doesn't know about it

Flag

useQuery 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:

statusFlagMeaning
idleisIdlenot called yet, or reset()
pendingisPendingmutationFn is running (v4 called this loading / isLoading)
errorisErrorit failed; see error
successisSuccessit 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 with retry when the endpoint is idempotent.
  • No deduping. Calling mutate twice sends two requests — mutations run in parallel. A double-clicked Save button creates two todos unless you disable it while isPending.
  • 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 with setQueryData.

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.

Medium

A save hook and its form, line by line

Flag

Split 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>
  );
}
  • onSuccess returns the invalidation. A promise returned from a hook-level callback is awaited before the mutation counts as finished, so isPending stays true until the list has refetched. Write onSuccess: () => { queryClient.invalidateQueries(...) } (braces, no return) 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 still pending while the list refetches; without it, it's already success.
  • 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 onSuccess clears the input. Clearing straight after mutate(...) would wipe the user's text even when the save fails. Callbacks passed to mutate run 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.error is typed Error by default in v5 (v4 used unknown), and stays set until the next mutate or reset().

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.

Complex

Where mutation callbacks surprise you

Flag

The 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?

Flag

A user double-clicks Save, which calls addTodo.mutate(draft). Nothing else guards it. What happens?

Flag

<button onClick={() => save.mutateAsync(draft)}> — and the save fails. What happens?

Flag

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?

Flag

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?

Flag

onSuccess: () => { queryClient.invalidateQueries({ queryKey: todoKeys.lists() }); } — the Save button is disabled={isPending}. What does the user see after a successful POST?

Flag

A successful addTodo.mutate(t, { onSuccess, onSettled }) on a hook that defines onSuccess and onSettled too. In what order do the four run?

Flag

A mutation's endpoint answers 500. With default options, how many times does mutationFn run?

Flag

Fill in the blank

The hook refreshes every list after a save, and stays pending until the refetch is done.

Flag
export 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.

Flag
addTodo.mutate(title, { : () => setTitle("") });

<button type="submit" disabled={addTodo.}>Save</button>

Create the post, then navigate to it — and handle a failed create yourself.

Flag
async 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.
Flag
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.
Flag
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);
Flag

Challenge

Fix the save button that double-submits and never refreshes

Flag

useAddTodo 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

Loading the in-browser editor…

Further reading