TanStack Query Invalidation Patterns in TypeScript

TanStack Query Invalidation Patterns in TypeScript
The first time I shipped a TanStack Query app to production, I learned something humbling: fetching data is the easy part. The hard part starts the day a user edits one todo and three other parts of the screen quietly keep showing the old value. A list, a sidebar count, a notification badge — all stale, all lying to the user. None of them broken in a way that throws an error. Just wrong.
This lesson is the version of that story I wish I'd had on day one. It walks through the invalidation patterns I actually reach for when working with TanStack Query and TypeScript on real apps — from the basic invalidateQueries call through query-key factories, predicate-based invalidation, optimistic updates, and the trade-offs between invalidation and direct cache writes. I'll lean on the cadence I use day to day: short punches when a rule is sharp, longer paragraphs when context matters.
1. Why invalidation matters more than fetching
Why is fetching data the part every tutorial teaches, while invalidation is the part that quietly decides whether your UI lies to users? The library handles deduplication, background refetching, and stale-while-revalidate semantics. Most engineers I've paired with grasp this in an hour.
The harder problem starts when the same data shows up in many places. When the user edits an item, every one of those surfaces has to either refetch or be patched. Get this wrong and the UI lies to the user. Get it overly aggressive and you melt the API with thundering-herd refetches every time someone toggles a checkbox.
Invalidation is the contract between mutations and queries: it tells the cache that this slice of server state is no longer trustworthy, please refetch on next observation. TanStack Query gives you several knobs for this, and the difference between a calm app and a chaotic one is usually the discipline applied to those knobs. It's also where TypeScript earns its keep — the difference between a queryKey typo silently caching forever and a compile-time refusal to ship.
2. The minimum viable mental model
Most engineers learn the whole TanStack Query API and use almost none of it past month two — three primitives carry almost all the weight in day-to-day code:
queryClient.invalidateQueriesmarks matching queries as stale and triggers a refetch on active observers.queryClient.setQueryDatawrites directly into the cache, skipping the network round trip entirely.queryClient.refetchQueriesforces a refetch regardless of stale state — the nuclear option you reach for rarely.
The official Query Invalidation guide defines invalidation precisely: the matched queries get marked as stale, and any active observer of those queries triggers a background refetch. Inactive queries are marked stale but only refetched when they next become active.
That last detail is the one that catches people. Invalidating a query nobody is subscribed to is essentially free — it just sets a flag. So you can be liberal with invalidation of leaf detail caches. You cannot be liberal with invalidation of a homepage list that ten thousand users are staring at.
Here's the most common shape you'll see in a mutation hook:
import { useMutation, useQueryClient } from "@tanstack/react-query";
type Todo = { id: string; title: string; done: boolean };
export function useToggleTodo() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: { id: string; done: boolean }) => {
const res = await fetch(`/api/todos/${input.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ done: input.done }),
});
if (!res.ok) throw new Error("toggle failed");
return (await res.json()) as Todo;
},
onSuccess: (todo) => {
qc.invalidateQueries({ queryKey: ["todos"] });
qc.invalidateQueries({ queryKey: ["todo", todo.id] });
},
});
}
This works. It also has a subtle problem: the strings todos and todo are now coupled across many files, and TypeScript does not catch typos in those literals. The compiler will happily watch you ship ["todos"] in five places and ["todoes"] in the sixth.
3. Query key factories are not optional
Six weeks into my first serious TanStack Query codebase I grepped for the string "todos" across the repo and found it written eight different ways across five files — the query-key factory is the single highest-leverage pattern for any non-trivial codebase, and adopting it on day one would have saved me a full afternoon of careful refactors. You centralize key construction in one module, type it, and never write a raw key inline again. The pattern is described in depth on TkDodo's blog on effective React Query keys, and it's the prerequisite for everything that follows.
A typical factory looks like this:
export const todoKeys = {
all: ["todos"] as const,
lists: () => [...todoKeys.all, "list"] as const,
list: (filters: { status?: "all" | "open" | "done" }) =>
[...todoKeys.lists(), filters] as const,
details: () => [...todoKeys.all, "detail"] as const,
detail: (id: string) => [...todoKeys.details(), id] as const,
};
Now invalidation expresses intent precisely:
invalidateQuerieswithtodoKeys.allblows away everything todo-related.invalidateQuerieswithtodoKeys.lists()hits every list variant but leaves detail caches alone.invalidateQuerieswithtodoKeys.detail(id)targets one detail entry and nothing else.
The hierarchical-prefix matching is the magic. TanStack Query matches by array prefix by default, so a key like ["todos", "list", { status: "open" }] is matched by invalidation of ["todos"], ["todos", "list"], or ["todos", "list", { status: "open" }]. You pick the right scope at the mutation site based on which slices actually changed. Just as importantly, as const makes the returned tuples readonly literal types, which means TypeScript can narrow against them downstream in your useQuery generics.
4. Choosing the right invalidation scope
When a mutation succeeds, there are four scopes to consider:
- The exact query, if the mutation returns the fresh data and the server response is authoritative.
- The sibling list queries, because adding or removing an item must redraw lists.
- Aggregate or derived queries — count badges, summaries — that depend on the same underlying data.
- Cross-entity queries that referenced the item indirectly, like a project list that shows todo counts per project.
The wrong default is invalidating everything todo-related on every mutation. It works, but it triggers cascading refetches and undermines the cache's purpose. The right default is to invalidate the narrowest scope that captures all surfaces showing the changed data. The factory makes that easy because each scope has a name and a stable shape.
For a toggle mutation that only flips done on one todo:
onSuccess: (todo) => {
qc.setQueryData(todoKeys.detail(todo.id), todo);
qc.invalidateQueries({ queryKey: todoKeys.lists() });
}
Here we surgically write the detail cache from the server response — no extra request — and invalidate only the lists, because filtered lists (open vs. done) may have to reorder or include/exclude the item. Aggregate queries that depend on a count of open todos should also live under todoKeys.all so they invalidate too, or you can chain another targeted invalidate call for them.
5. The exact and predicate filters
Sometimes the prefix-match default is wrong. Two filters refine matching:
exact: truerequires the keys to match exactly, not by prefix. Useful when you want only the bare["todos"]query and not all descendants.predicateaccepts a function that runs against every cached query. Use this for cross-cutting conditions like any query whose third element hasstatus: "open".
A predicate example:
qc.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "todos" &&
query.queryKey[1] === "list" &&
(query.queryKey[2] as { status?: string } | undefined)?.status === "open",
});
Predicates open up powerful selective patterns, but they pay an iteration cost over the cache. The filters reference in the official TanStack Query GitHub repo documents the full filter shape including type ("active" | "inactive" | "all") and stale flags. Reach for predicates only when prefix matching cannot express the intent — the more declarative your factory, the rarer this should be.
6. Optimistic updates and the rollback contract
For latency-sensitive UI — toggles, reorders, likes — invalidation-after-success feels sluggish. The fix is the optimistic update: mutate the cache immediately, send the network request, and roll back if the server rejects. The pattern has four steps across the onMutate, onError, onSettled lifecycle.
useMutation({
mutationFn: toggleTodo,
onMutate: async (input) => {
await qc.cancelQueries({ queryKey: todoKeys.detail(input.id) });
const previous = qc.getQueryData<Todo>(todoKeys.detail(input.id));
qc.setQueryData<Todo>(todoKeys.detail(input.id), (old) =>
old ? { ...old, done: input.done } : old
);
return { previous };
},
onError: (_err, input, ctx) => {
if (ctx?.previous) {
qc.setQueryData(todoKeys.detail(input.id), ctx.previous);
}
},
onSettled: (_data, _err, input) => {
qc.invalidateQueries({ queryKey: todoKeys.detail(input.id) });
qc.invalidateQueries({ queryKey: todoKeys.lists() });
},
});
Three details that bite people:
cancelQueriesis mandatory. If a background refetch is in flight when you write to the cache, that fetch can land afterwards and clobber your optimistic value. Cancelling first prevents the race.- The context object returned from
onMutateis the rollback payload. TypeScript infers it as the third generic ofuseMutation; you can declare it explicitly to enforce shape and make theonErrorparameter strongly typed. onSettledruns after bothonSuccessandonError, which is the right place to reconcile with the server — because rolling back optimistically is not the same as having the correct authoritative value.
7. When to setQueryData instead of invalidating
Direct cache writes via setQueryData are the cheapest possible refresh — no network call, instant UI. They make sense when:
- The mutation response contains the complete authoritative entity, including any server-computed fields.
- The cached shape is a flat entity (a single Todo), not a derived aggregate.
- You're confident there are no other server-side side effects — computed columns, triggers, denormalized fields — that the response does not capture.
For list updates, mixing setQueryData with invalidation is common. Insert a freshly created item into the front of the list cache for instant feedback, then invalidate so the server's canonical ordering wins on the next refetch. The TypeScript signature of setQueryData<T> lets you pass either a fresh value or an updater function (old: T | undefined) => T. I almost always reach for the updater form — it's safer, because it accounts for the cache potentially having been evicted between calls.
8. Anti-patterns to avoid
A few patterns appear in early codebases and rarely survive contact with production traffic:
-
Invalidating with an empty queryKey: passing
{}matches every query in the cache. There is almost no scenario where this is intentional outside of a logout or cache reset, in which casequeryClient.clear()is more honest about the intent. -
Using inline string keys: a literal
["todos", filters]written in five hook files will inevitably drift, and refactors miss callers. -
Invalidating inside
useEffect: invalidation belongs at mutation completion or in response to explicit events, not on render. Using effects creates loops or hides the real trigger. -
Forgetting to invalidate aggregate queries: a count badge subscribed to
["todos", "count"]will not update if you only invalidate the list family. Either include the count undertodoKeys.allso it invalidates too, or invalidate explicitly. -
Optimistic update without
cancelQueries: as noted above, the in-flight refetch will undo your optimistic value when it lands, often a few hundred milliseconds after the user has moved on. -
Invalidating from inside the query function itself: this turns into an infinite loop and is one of the few ways to genuinely DOS your own API.
9. Putting it together
The mental model I use after several large TanStack Query codebases looks like this:
- One query-key factory per resource module, hierarchical, typed with
as constfor literal inference. - Mutations declare their invalidation scope explicitly in
onSuccessoronSettled, never with wildcards. - Optimistic updates are reserved for high-frequency interactions where latency is the user's complaint, and they always include cancel plus rollback plus settle.
setQueryDatareplaces invalidation only when the server response is authoritative for the cached shape.- Predicates are an escape hatch for selective invalidation, not a default.
Combined, these patterns keep the cache coherent without thundering-herd refetches, and TypeScript catches the typos that string-keyed code lets through. The library is content to be quiet and correct in the background; your job is to give it precise enough instructions that it does not have to guess.
For deeper reading, the TanStack Query mutations guide covers the full onMutate / onSuccess / onError / onSettled lifecycle, and the official examples folder on the TanStack Query GitHub repo has full working setups for the optimistic-update plus rollback flow you can clone and adapt. Pick one resource in your own codebase, build the factory, retrofit two mutations, and the discipline will spread by gravity from there.