📚 State Management series — read it in full on warrendeleon.com, where new parts land first.
Tags survive a rename, keys don't
Declarative tags scale to teams. String-based keys don't.
TanStack Query's keys are shorter, lighter, and pleasant to read inside a single codebase. For a feature team that owns every query, the key-based API is the obvious choice. RTK Query's tag system adds ceremony you don't need at that size.
The shape that works for one team becomes a liability across team boundaries. The failure mode shifts from "fix it before merge" to "find it from a support ticket."
One order, two teams
The orders team ships a placeOrder mutation. The catalogue team has a getProduct query that returns an item's details and stock level. When the order succeeds, the cached product is stale (its stock count just changed), and something has to mark it for refetch.
With RTK Query tags
The catalogue team declares what their query holds:
getProduct: builder.query({
query: (id) => `/products/${id}`,
providesTags: ['Product'],
}),
The orders team declares what their mutation affects:
placeOrder: builder.mutation({
query: (order) => ({ url: '/orders', method: 'POST', body: order }),
invalidatesTags: ['Product'],
}),
When the mutation succeeds, the library walks its cache, finds queries that provide the Product tag, and refetches the ones currently mounted. The orders team never reads the catalogue's code. They don't need to know what query key the catalogue uses or what shape the cache entry takes. They declare intent at a categorical level and the library wires it.
Both teams are coupled to the string 'Product'. That coupling lives in a shared tagTypes list declared in the API module, so it has a single source of truth.
With TanStack Query keys
TanStack invalidates by key, not by category. The orders team writes:
queryClient.invalidateQueries({ queryKey: ['product'] });
For that line to be correct, the orders team has to know the catalogue team's exact query key shape. They go look at the catalogue's code, find the useQuery call, read the key, and copy it into their own invalidation.
The teams are coupled to the same string 'product', but the coupling lives in a different place. It's a magic string in the orders team's mutation handler, with no contract pointing back to the catalogue's query.
What happens when teams drift
Both approaches produce the same outcome when teams stay coordinated. The cached product refetches, the UI updates, the user sees the new stock level. The approaches diverge when coordination slips.
Take a rename. The catalogue team is refactoring. The domain term has shifted. What they used to call "product" is now called "listing" across the API, the docs, and the team's day-to-day language. They rename the query key:
// before
useQuery({ queryKey: ['product', productId], queryFn: fetchProduct });
// after
useQuery({ queryKey: ['listing', listingId], queryFn: fetchListing });
They ship.
In an RTK Query world with tags, the rename has no effect on the orders team's invalidation. Tags are independent of query keys. The catalogue team's query still provides 'Product'. The orders team's mutation still invalidates 'Product'. The wiring holds.
In a TanStack Query world with keys, the rename breaks the orders team silently. invalidateQueries({ queryKey: ['product'] }) now matches zero cached queries. The mutation succeeds. The library doesn't surface an error because finding zero matches isn't one. The user's product page stays stale until they navigate away and back, or until a support ticket surfaces the bug.
No type error. No compile failure. No runtime exception. Stale UI in production.
Where the coupling lives
Both approaches encode the same coupling between the two teams. The difference is where the coupling lives, and what surfaces when it breaks.
Tags couple the teams through a category name: an abstract noun describing a kind of data. It lives in a shared enum. Renames need coordination through that enum, and the rename surfaces at compile time: inside one createApi module, immediately; across independently shipped apps, at each app's next build against the updated shared package.
Keys couple the teams through a string identifier: the literal shape that identifies a cached entry. It lives wherever someone types it. Renames don't need coordination because nothing forces it. They surface when the bug hits production.
Neither behaviour is baked into the words "tag" and "key". A team can hard-code a tag string outside the enum, and a TanStack team can build shared, typed key factories that surface renames just as loudly. The enforcement comes from the shared contract, and the difference is which way each API leans: RTK Query asks for tags from a declared tagTypes list, so the contract is the path of least resistance; TanStack accepts any array, so the contract is something you have to build and police yourself.
That gap in surfacing isn't a flaw in TanStack Query's design. The key-based API is shorter, lighter, and well-suited to a single team that holds every query in its head. The shape stops fitting when the people writing the invalidation and the people writing the query stop being the same people.
Which one fits your team
One team, one codebase. If that's your app, the difference is mostly aesthetic. TanStack's key-based API is shorter and adds no ceremony. The risk of silent invalidation drift is small because you have full visibility into every query key.
Multiple teams shipping independently. Here the failure mode of key-based invalidation starts to compound. Each team's refactor is a possible silent break for any other team that referenced their keys. You can build conventions to soften that (query key factories, shared key constants, code review checklists, integration tests across features), but those are conventions, not enforcement: you're rebuilding what the tag system gives you for free.
A library choice still open, with federation or multi-team work ahead. In that case the tag system is worth understanding before the API shape feels arbitrary. Picking between the two libraries means picking which failure modes you're willing to live with.
The Module Federation series takes up the broader question of state management across independently shipped remotes, in One shared store for the server half and Client state across the seam for the client half. If you're new to the server-vs-client-state split this one assumes, start here.
Top comments (0)