skip to content

In TanStack Query v5, after a mutation that creates a todo succeeds, how do you make the todo lists on screen show it?

level: juniorimportance: must knowfreq 70%

answer

  1. tell the cache it is out of date
  2. a QueryClient method, not a refetch
  3. prefix of the query key
  4. on-screen now, off-screen later

basics

~10 s

In the mutation's onSuccess, call queryClient.invalidateQueries({ queryKey: ['todos'] }). Every query whose key starts with 'todos' is marked stale; those currently rendered refetch in the background at once, the rest refetch when next mounted.

solid answer

~40 s

Get the client with `useQueryClient()` and, in `useMutation`'s `onSuccess`, call `queryClient.invalidateQueries({ queryKey: ['todos'] })`. Two things happen to every query whose key starts with `['todos']`: it is marked **invalidated**, which makes it stale whatever its `staleTime`, and - if it is **active**, meaning a mounted component observes it - it is refetched in the background, with the old list staying on screen until the new one arrives. Matching queries that are not on screen are only marked; they refetch the next time a component mounts them. Because matching is by prefix, detail queries such as `['todos', 'detail', 5]` are swept up too, so narrow the key to `['todos', 'list']` if only lists changed. Return the promise from `onSuccess` if the mutation should stay pending until the lists have refreshed.

go deeper

for a junior

Recall the call: queryClient.invalidateQueries({ queryKey: ['todos'] }) inside the mutation's onSuccess, and that matching lists refetch in the background.

for a middle

Explain the two effects - mark stale, refetch active - and walk through which on-screen and off-screen queries refresh, and when.

for a senior

Scope invalidations tightly so writes do not trigger wasted refetches, and return the promise when the UI must wait for fresh lists.

for a principal

Set a convention that ties each mutation to the key prefixes it invalidates, so teams do not choose between over-invalidating and stale screens case by case.

## The standard move: invalidate after the write TanStack Query v5 does not know that a `POST /todos` changed what `GET /todos` returns. After a mutation succeeds, you tell it, using the **`QueryClient`**: ```tsx import { useMutation, useQueryClient } from '@tanstack/react-query' function useCreateTodo() { const queryClient = useQueryClient() return useMutation({ mutationFn: createTodo, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos', 'list'] }), }) } ``` `invalidateQueries` does two things to every matching query: 1. it **marks it invalidated** - the query is now stale, and this overrides any `staleTime`, so the next opportunity to refetch will be taken; 2. it **refetches the active ones** - by default only queries that a mounted component currently observes (with `enabled` not false) are refetched immediately, in the background. "In the background" matters: the list keeps showing its previous rows while the request runs (`status` stays `'success'`, `fetchStatus` becomes `'fetching'`), and then swaps to the new rows. There is no flash back to a loading state. ## Which views refresh, and when Suppose the cache holds these queries when a new todo is created and the code above runs with the prefix `['todos', 'list']`: | Query key | On screen? | Matches `['todos', 'list']`? | What happens | |---|---|---|---| | `['todos', 'list', { filter: 'all' }]` | yes | yes | refetched immediately | | `['todos', 'list', { filter: 'done' }]` | no (other tab of the UI) | yes | marked stale; refetched when next mounted | | `['todos', 'detail', 5]` | yes | no | untouched | | `['user', 'me']` | yes | no | untouched | Had the code used the broader prefix `['todos']`, the detail view of todo 5 would also be refetched - a wasted request, since creating a new todo does not change todo 5. **Prefix matching** is the reason: a filter key matches every cached key that begins with the same elements. For the off-screen list, the stale mark is enough. When the user switches to the "done" tab, the query mounts, sees stale data and refetches on mount (the default `refetchOnMount: true` refetches stale data). Meanwhile nothing was spent on a list nobody was looking at. ## Where to call it - **`onSuccess`** - invalidate only when the write worked; the usual choice for creates. - **`onSettled`** - invalidate after success *or* error; useful when a failed write may still have changed something on the server, or after an optimistic update that needs the server truth either way. - **Return (or `await`) the promise.** `invalidateQueries` returns a promise that resolves when the refetches have finished. If the callback returns it, the mutation stays `isPending` until the lists are fresh, so a "Saving..." state does not end before the new row is visible. Several unrelated keys can be invalidated together with `Promise.all([...])` returned from the callback. ## Common mistakes - **Calling `refetch()` from the list component** after the mutation - it couples the form to every list and ignores off-screen lists entirely. - **Invalidating everything** with `invalidateQueries()` and no filters - correct but wasteful: every active query in the app refetches. - **Expecting a disabled query to refetch** - disabled queries are skipped by the refetch step; they are only marked stale. - **Forgetting that invalidation needs a round trip.** When the mutation response already contains the new item, writing it into the cache with `setQueryData` avoids the extra request; invalidation is the simpler, always-correct default. ## Why this is a TanStack Query idiom The library deliberately keeps a **keyed cache**, not a normalized one: it does not know that the new todo belongs in a particular list with a particular sort order. Invalidation hands that question back to the server, which already knows the answer. That is why "invalidate the affected keys" is the default answer, and manual cache writes are an optimisation on top of it. ## Checking that it works The TanStack Query Devtools make the behaviour visible. Right after the mutation succeeds: - the on-screen list flips to a fetching state and then back to fresh, and the network panel shows exactly one new `GET` for it; - the off-screen list shows as stale and inactive, with no request; - the detail query of todo 5 shows no change at all when the narrower prefix is used. If the detail query also refetches, the filter is broader than the write required.

  • Why does the list not flash back to its loading state while the invalidation refetch runs?
    Invalidation keeps the cached data. The query stays in `status: 'success'` and only its `fetchStatus` becomes `'fetching'`, so the old rows stay on screen until the refetched rows replace them.
  • A list for the 'done' filter was not mounted when you invalidated. Is it ever refreshed?
    Yes. It was marked invalidated, which makes it stale regardless of `staleTime`. When a component mounts it again, the default `refetchOnMount: true` sees stale data and refetches, while the cached rows show in the meantime (if they have not been garbage-collected).

saying these in an interview costs you the question

  • Calls refetch on each list component after the mutation instead of invalidating by key.
  • Believes invalidateQueries clears the cached data and shows a loading state again.
  • Thinks off-screen matching queries are refetched immediately by default.
  • Invalidates with no filters at all to be safe after every write.
  • Assumes a disabled query refetches when it is invalidated.