skip to content

In TanStack Query v5, a dialog saves a new todo, its Save button re-enables and the dialog closes, but the list only shows the todo a moment later. Why, and how do you fix it?

level: seniorimportance: should knowfreq 45%

answer

  1. the mutation finished before the refetch
  2. callbacks that return promises are awaited
  3. return, do not just call
  4. Promise.all for several keys

basics

~20 s

The onSuccess callback calls invalidateQueries without returning its promise, so the mutation settles as soon as the POST succeeds while the refetch is still running. Return or await the invalidateQueries promise; the mutation then stays pending until the list has refetched.

solid answer

~40 s

A `useMutation` in v5 stays `isPending` until the `mutationFn` **and** the awaited lifecycle callbacks have finished: if `onSuccess` or `onSettled` returns a promise, the mutation waits for it before switching to `success`. `onSuccess: () => { queryClient.invalidateQueries(...) }` starts the refetch but returns nothing, so the mutation is marked successful immediately - the button re-enables and anything tied to success, like closing the dialog, runs - while the list is still fetching. The fix is to return (or `await`) the promise: `onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos', 'list'] })`. It resolves when the active refetches finish, so `isPending` now covers the whole round trip. For several keys, return `Promise.all([...])`. The trade-off is that a slow list query now delays the success state; writing the returned item with `setQueryData` avoids that wait.

code

tsx · 25 lines
tsx
import { useMutation, useQueryClient } from '@tanstack/react-query'

function NewTodoDialog({ onClose }: { onClose: () => void }) {
  const queryClient = useQueryClient()
  const createTodo = useMutation({
    mutationFn: postTodo,
    onSuccess: () =>
      queryClient.invalidateQueries({ queryKey: ['todos', 'list'] }),
  })

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        const title = String(new FormData(e.currentTarget).get('title'))
        createTodo.mutate({ title }, { onSuccess: onClose })
      }}
    >
      <input name="title" />
      <button disabled={createTodo.isPending}>
        {createTodo.isPending ? 'Saving...' : 'Save'}
      </button>
    </form>
  )
}

go deeper

for a junior

Recall that returning the promise from invalidateQueries in onSuccess keeps the mutation pending until the refetch is done.

for a middle

Explain the awaited callback sequence - mutationFn, onSuccess, onSettled, then success - and what the invalidation promise resolves on.

for a senior

Diagnose double submits and flicker from fire-and-forget invalidation, and decide per screen whether to await a refetch or write the response with setQueryData.

for a principal

Set a team rule for mutation hooks - return invalidations, await only what the next screen needs - so success states mean the same thing everywhere.

## The symptom A create-todo dialog uses `useMutation`. The Save button shows "Saving..." while `mutation.isPending` is true, and the dialog closes when the mutation succeeds. In production users see this sequence: 1. click Save - button shows "Saving..."; 2. the POST returns - button re-enables, dialog closes; 3. the list behind it still shows the old rows; 4. a moment later the new todo appears. Users click Save again in that gap and create duplicates. ## The cause: fire-and-forget invalidation The mutation was written like this: ```tsx useMutation({ mutationFn: createTodo, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['todos', 'list'] }) }, }) ``` The braces make the arrow function return `undefined`. `invalidateQueries` does start a background refetch - but nothing waits for it. In TanStack Query v5, a mutation's lifecycle is sequential, and the source awaits each hook-level callback before moving on: - `mutationFn` resolves; - `onSuccess` runs - **if it returns a promise, it is awaited**; - `onSettled` runs - awaited the same way; - only then is the mutation marked `success`, and `isPending` becomes `false`. With a callback that returns `undefined`, the second and third steps take no time, so success is reported while the list refetch is still in flight. ## The fix: return the promise ```tsx import { useMutation, useQueryClient } from '@tanstack/react-query' function useCreateTodo() { const queryClient = useQueryClient() return useMutation({ mutationFn: createTodo, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos', 'list'] }), }) } ``` `invalidateQueries` returns a promise that resolves once the refetches it started (active matches, by default) have settled. Returning it from `onSuccess` extends `isPending` until the list holds the new todo, so the button re-enables and the dialog closes only once the new row is there. For more than one key, return one combined promise: ```ts onSuccess: () => Promise.all([ queryClient.invalidateQueries({ queryKey: ['todos', 'list'] }), queryClient.invalidateQueries({ queryKey: ['stats'] }), ]), ``` ## Details that decide whether the fix holds | Detail | Effect | |---|---| | refetch fails | the promise still resolves (unless `throwOnError` is passed), so a failed refetch does not turn the mutation into an error | | `refetchType: 'none'` | nothing is refetched, so the promise resolves immediately and nothing is gained | | matched query is off screen | not refetched by default; the promise does not wait for it | | a refetch of the list (which already has rows) is running | by default (`cancelRefetch: true`) it is cancelled and restarted, so the rows reflect the new todo | The last row matters for correctness: a background refetch that started just before the POST finished could return rows without the new todo. Restarting it avoids showing that stale response as the "fresh" result. (A first load with no data yet is not restarted; the running request is reused.) ## The trade-off Awaiting the refetch makes the success state honest, but it also makes it slower: the user waits for the POST **and** the list query. Two ways to shorten it: - **Write the created item into the cache** with `setQueryData` (or `setQueriesData` for several lists) from the mutation response, then invalidate without waiting - the list updates instantly and the refetch reconciles order and counts in the background. - **Await only what the next screen needs.** If the dialog closes onto the list, await the list; do not also await a statistics panel that is not visible. ## How to spot it in review - `onSuccess: () => { queryClient.invalidateQueries(...) }` with braces and no `return` or `await`; - `async` callbacks that call `invalidateQueries` without `await`; - a UI that disables a button on `isPending` but still allows double submits right after success. Each one is a mutation reporting success before the cache agrees with the server. ## Proving the fix in a test Make the list endpoint slow in the test's mock server, submit the form, and assert that the Save button stays disabled until the list request has resolved and the new todo is rendered. With the fire-and-forget version, the assertion fails: the button re-enables while the list request is still pending. Create the test `QueryClient` with retries off so a failure shows up at once instead of after the retry backoff.

  • Does returning the invalidateQueries promise make the mutation fail if the list refetch fails?
    No. By default `invalidateQueries` swallows individual refetch errors and resolves anyway; only `throwOnError: true` in its options makes it reject. The mutation still reports success, and the list query shows its own error state.
  • Why is it useful that invalidateQueries cancels a list fetch that is already running?
    A background refetch that started before the POST completed may return rows without the new todo. With the default `cancelRefetch: true`, a running fetch of a query that already has data is cancelled and started again, so the result the mutation waits for reflects the write.

saying these in an interview costs you the question

  • Believes the mutation always stays pending until every invalidation it starts has refetched.
  • Uses an async onSuccess that calls invalidateQueries without await and expects it to be awaited.
  • Thinks a failed refetch inside the returned promise turns the mutation into an error.
  • Returns an invalidation with refetchType none and expects the mutation to wait for data.