Data Fetching with TanStack
Data Fetching with TanStack

Invalidation & cache writes

Full · ~35 min
After this unit you can
  • Choose invalidateQueries, setQueryData, refetchQueries, resetQueries or removeQueries by what each does to the data on screen, to staleness, and to active vs inactive queries
  • Write query filters that reach exactly the intended entries: a prefix, exact: true, type/refetchType and a predicate
  • Write cached data immutably with setQueryData/setQueriesData updaters, and explain why an in-place edit leaves the screen unchanged
  • Decide when to write a response straight into the cache and when to invalidate, and predict what an awaited invalidateQueries waits for

Read

Simple
1 / 2

The server changed — five QueryClient calls, five different effects

Flag

After a write, the cache holds data the server no longer agrees with. TanStack Query v5 gives you five QueryClient methods for that moment, and they are not interchangeable. Ask two questions: do I already have the new data? and is anyone looking at the old data?

CallData on screenRequestUse it when
invalidateQueries(filters)keptrefetches matches on screen now; the rest when next usedyou don't have the new data
setQueryData(key, data)replaced at oncenoneyou do have the new data
refetchQueries(filters)keptrefetches every match now, on screen or notrarely — usually invalidate instead
resetQueries(filters)dropped back to its initial state (a spinner)then refetches the ones on screenyou want a hard reset, e.g. a form's initialData
removeQueries(filters)entry deleted, mounted views not toldnonecleaning up data no screen is showing

The everyday pair is the first two:

// We created a todo; we don't know where it lands in each sorted, filtered list.
await queryClient.invalidateQueries({ queryKey: todoKeys.lists() });

// We renamed todo 5 and the server sent back the updated todo.
queryClient.setQueryData(todoKeys.detail(5), updatedTodo);

Invalidation is the default answer. It marks every matching entry stale — overriding staleTime — and refetches the matching queries that components are using right now, in the background, with their data still on screen. An entry nobody is using is only marked; it refetches when a component next asks for it. That's why "invalidate the whole family" is cheap: you pay for what's visible.

setQueryData is the shortcut when you hold the truth. It writes synchronously, makes no request, and every component reading that key re-renders with the new value. It counts as a fresh fetch, so staleTime starts again from now.

The other three are specialist tools. refetchQueries re-downloads inactive entries too (and doesn't mark anything stale). resetQueries throws data away first, so the user sees a loading state. And removeQueries deletes entries without telling mounted components — the screen keeps the old data until something re-renders it, then shows a hard load.

Medium

Writing a response into the cache, line by line

Flag

A rename endpoint returns the full updated todo. The detail view and every cached list show that todo. Refetching all of them would work, but the response already is the new data. Here is the cache update in full:

function applyRenamedTodo(queryClient: QueryClient, saved: Todo) {
  // 1. The detail: we hold its exact new value.
  queryClient.setQueryData(todoKeys.detail(saved.id), saved);

  // 2. Every cached list: patch the one row, immutably.
  queryClient.setQueriesData<Todo[]>({ queryKey: todoKeys.lists() }, (old) =>
    old?.map((todo) => (todo.id === saved.id ? { ...todo, title: saved.title } : todo)),
  );

  // 3. Lists sorted by title on the server: the row may have moved. Let the server decide.
  void queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
}

Step 1 — setQueryData for the one entry you can name. It writes synchronously and makes no request; every component showing that detail re-renders with the new value. If no entry exists for the key yet, one is created — handy for priming a detail page before the user opens it.

Step 2 — setQueriesData for a family. It takes filters instead of a key and runs the updater on every existing match (it never creates entries). Two details in the updater matter:

  • old can be undefined, for an entry that exists but has no data yet. Returning undefined leaves that entry untouched, which is why old?.map(...) is safe.
  • Return a new array with a new object. The docs are blunt: don't mutate cached data in place. Here is what happens if you do:
queryClient.setQueryData<Todo[]>(todoKeys.list(filters), (old) => {
  old!.find((t) => t.id === saved.id)!.title = saved.title; // ❌ same array, same object
  return old;
});

Run against v5.104 with a list on screen: the cache holds the new title, but the component doesn't re-render. useQuery re-renders a component only when a result field it reads has changed ("tracked properties"), and data is the very same array as before — so the screen keeps the old title until something else re-renders it. An immutable update hands the cache a new reference, and that is what it notices.

Step 3 — invalidate what you can't compute. If the server sorts lists by title, or the rename changes which filter a todo belongs to, a client-side patch could put the row in the wrong place. Invalidate the lists and keep step 2 only as an instant preview (or drop it). Note the filter: todoKeys.lists(), not todoKeys.all — the broader one would also refetch the detail you wrote in step 1.

The rule of thumb:

You have…Do
the exact new value for a keysetQueryData
a change you can apply to every cached copy, row by rowsetQueriesData with an immutable updater
a change whose effect on sorting, filtering or totals only the server knowsinvalidateQueries

Complex

What the cache calls really do at the edges

Flag

Each of these was run against TanStack Query v5.104 with a component on screen.

removeQueries on a query someone is showing

function logout() {
  queryClient.removeQueries(); // ❌ the dashboard is still mounted
  navigate("/login");
}

The entries are deleted, but mounted components aren't notified: the dashboard keeps rendering the old user's data. The moment anything re-renders it, its useQuery finds no entry, creates a new one, and shows a hard loading state while it fetches — possibly as the logged-out user. (v5 removed useQuery's own remove() for exactly this reason: removing an active query "will just trigger a hard loading state with the next re-render.") Leave the screen first, then clear the cache (queryClient.clear() empties it entirely), or invalidate what should refresh.

resetQueries as a "Refresh" button

resetQueries returns matches to their initial state before refetching the active ones. Without initialData, the initial state is pending with no data, so the table vanishes behind a spinner. A refresh that keeps the table on screen is invalidateQueries (or refetch() from the hook).

What await invalidateQueries(...) waits for

It resolves when the active queries it refetched have settled (inactive ones are only marked, so there's nothing to wait for). Two consequences:

  • It resolves even if the refetch failed. Failures are swallowed unless you pass { throwOnError: true } as the second argument; after a failed refetch, data is still the old value and status is error. "Awaited invalidation" does not mean "fresh data".
  • Returning it from a mutation's onSuccess keeps the mutation pending until the refetch is done, so a "Saving…" state lasts until the list really shows the change. The mutations unit builds on this.

Invalidating while a fetch is already running

A refetch started before your write may return pre-write data. By default (cancelRefetch: true) an invalidation cancels an in-flight refetch of a query that already has data and starts a new one, so what lands was requested after the write. A first load (no data yet) that's in flight is joined rather than restarted.

Queries that ignore you

  • Disabled queries (enabled: false) aren't refetched by invalidateQueries or refetchQueries, though they're still marked stale.
  • staleTime: "static" queries ignore invalidation entirely; Infinity ones don't.
  • invalidateQueries() with no filter marks everything stale and refetches every active query in the app. It's a sledgehammer; aim it with a key.

Quiz

A rename's response contains the full updated todo. Which call shows it in the open detail view without another request?

Flag

The open-todos list is on screen; the done-todos list is cached but not mounted. You call invalidateQueries({ queryKey: ['todos'] }). What happens to each?

Flag

A logout handler calls queryClient.removeQueries() and only then navigates away, so for a moment the dashboard is still mounted. What does that dashboard do?

Flag

A Refresh button calls queryClient.resetQueries({ queryKey: todoKeys.lists() }), and users complain the table flashes a spinner. Why, and what fits better?

Flag

queryClient.setQueryData(['todos'], (old) => { old.push(newTodo); return old; }) runs while the list is on screen. What happens?

Flag

After creating a todo you want every cached list marked stale, the lists on screen refreshed now, and hidden lists refreshed only when they're shown. Which call?

Flag

When does await queryClient.invalidateQueries({ queryKey: todoKeys.lists() }) resume?

Flag

A rename's response has the updated todo, but lists are sorted by title on the server. What should the cache update do?

Flag

Fill in the blank

Patch the saved todo into every cached list, immutably, leaving entries without data alone.

Flag
queryClient.<Todo[]>({ queryKey: todoKeys.lists() }, (old) =>
  old?.((t) => (t.id === saved.id ? { ...t, title: saved.title } : t)),
);

The mutation response is the full, updated todo. Put it in the detail entry without a request.

Flag
queryClient.(todoKeys.detail(saved.id), saved);

Mark only the bare ['todos'] entry stale — not the keys under it — and don't refetch anything now.

Flag
queryClient.invalidateQueries({ queryKey: ["todos"], : true, : "none" });

Invalidate every todo query except details, with a check the key prefix can't express.

Flag
queryClient.invalidateQueries({
  : (query) => query.queryKey[0] === "todos" && query.queryKey[1] !== "detail",
});

Predict the output

function Todos() {
  const { data } = useQuery({ queryKey: ["list"], queryFn: async () => ["a"] });
  return <p>{data?.join(",")}</p>;
}

// <Todos /> has rendered "a". Then, from a button elsewhere:
queryClient.setQueryData<string[]>(["list"], (old) => { old!.push("b"); return old; });
console.log(queryClient.getQueryData(["list"]));
Flag
// ["todos", "open"] is on screen; ["todos", "done"] is cached but unmounted.
// Each has fetched once: fetchCount is { open: 1, done: 1 }.

await queryClient.invalidateQueries({ queryKey: ["todos"] });
console.log(fetchCount);

await queryClient.refetchQueries({ queryKey: ["todos"] });
console.log(fetchCount);
Flag
function Profile() {
  const [tick, setTick] = useState(0);
  const { status, data } = useQuery({ queryKey: ["me"], queryFn: fetchMe });
  return <p onClick={() => setTick(tick + 1)}>{status}: {data?.name}</p>;
}

// The paragraph shows "success: Ada". Then:
queryClient.removeQueries({ queryKey: ["me"] });
// …a moment later, the user clicks the paragraph.
Flag

Challenge

Keep the cache in sync with server pushes

Flag

The todo app keeps a websocket open, and the server pushes two events. Their handlers live in cache-sync.ts, and both are wrong:

  • onTodoRenamed(queryClient, todo) — someone renamed a todo, and the push carries the full, updated row. Every connected client receives it, so it must not cause a request: a busy board would turn each rename into a request storm. Right now the list on screen keeps the old title, and the open detail only updates after a refetch.
  • onTodoCreated(queryClient) — someone created a todo. The push doesn't include it, so only the server knows which lists it belongs in. Right now it shows up in the list on screen, but switching to the other filter shows that list without it.

The app runs with staleTime: 60_000, so nothing refetches by itself: the cache is only as current as these handlers make it. keys.ts, TodoScreen.tsx and api.ts are fine as they are.

Your task

Edit cache-sync.ts so the tests pass:

  • A pushed rename shows in the list on screen at once, without a request
  • A pushed rename shows in the open detail at once, without a request
  • A created todo appears in the list on screen, and the current rows stay on screen while it refreshes
  • A list that was hidden when the todo was created isn't fetched until it's shown again; then its cached rows show at once and the new todo arrives

Loading the in-browser editor…

Further reading