Invalidating RTK Query With providesTags
RTK Query’s tag system is the most declarative invalidation model of the major clients: endpoints announce what they contain, mutations announce what they changed, and the library works out which subscriptions need to refetch. The failures are correspondingly specific — a tag type that differs by one character silently matches nothing, an error branch that provides no tags leaves a broken endpoint unreachable, and a mutation that invalidates on failure refetches data that did not change. This page covers the wiring, under Tag-Based Invalidation Systems, applying the vocabulary from Designing an Invalidation Tag Taxonomy. For the Apollo equivalent see Implementing Tag-Based Invalidation in Apollo.
Prerequisites
- Redux Toolkit 2.x with
createApifrom@reduxjs/toolkit/query/react. - A tag vocabulary decided in advance rather than grown per endpoint.
- Endpoints whose responses include stable entity ids.
Step 1 — Declare tagTypes and a Tag Helper
tagTypes is the closed vocabulary. A helper that handles the error branch removes the most common source of unreachable endpoints.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
// The closed vocabulary. A tag outside this list is a type error.
tagTypes: ['Todo', 'Board', 'Comment', 'User'],
// Long enough that navigating away and back does not refetch.
keepUnusedDataFor: 300,
endpoints: () => ({}),
});
type TagType = 'Todo' | 'Board' | 'Comment' | 'User';
/** Rows plus a LIST sentinel — and the sentinel alone when the query failed. */
export function listTags<T extends { id: string }>(type: TagType) {
return (result: T[] | undefined) =>
result
? [...result.map(({ id }) => ({ type, id }) as const), { type, id: 'LIST' } as const]
// Without this, a failed query is untagged and no mutation can prompt a retry.
: [{ type, id: 'LIST' } as const];
}
Cache Behavior Analysis: providesTags is invoked with (result, error, arg), and result is undefined whenever the request failed — so a naive result.map(...) both throws and, once guarded with result ? … : [], leaves the errored entry with no tags at all. An entry with no tags cannot be matched by any invalidation, so it stays in its error state until the component remounts, which is why users report that “retrying does nothing”. keepUnusedDataFor is RTK Query’s equivalent of gcTime: it governs how long an entry with no subscribers survives, and setting it too low means invalidation frequently has nothing left to invalidate.
Step 2 — Provide Response-Derived Tags
Every query endpoint declares what it contains, computed from the response rather than from its arguments.
export const todoApi = api.injectEndpoints({
endpoints: (build) => ({
getTodos: build.query<Todo[], TodoFilters>({
query: (filters) => ({ url: '/todos', params: filters }),
// Every returned row, plus the collection sentinel.
providesTags: listTags<Todo>('Todo'),
}),
getTodo: build.query<Todo, string>({
query: (id) => `/todos/${id}`,
// A detail endpoint contains exactly one record and no collection.
providesTags: (result, _error, id) => [{ type: 'Todo', id }],
}),
getBoardTodos: build.query<Todo[], string>({
query: (boardId) => `/boards/${boardId}/todos`,
// Two vocabularies: the todos it contains, and the board it belongs to.
providesTags: (result, _error, boardId) => [
...(result ?? []).map(({ id }) => ({ type: 'Todo' as const, id })),
{ type: 'Todo' as const, id: 'LIST' },
{ type: 'Board' as const, id: boardId },
],
}),
}),
});
Cache Behavior Analysis: getTodos and getBoardTodos both provide Todo:LIST, so a create invalidating that sentinel refetches both — correct, because a new todo may belong to either view and neither endpoint can determine that from the client. Providing Todo:t2 from three different endpoints is exactly the intended design: a single invalidatesTags: [{ type: 'Todo', id: 't2' }] reaches every cached view containing that record without any of them knowing about the others. Note that getTodo takes id from its argument rather than the response, which is safe here because the id is the identity of the request itself and does not depend on what came back.
Step 3 — Invalidate Precisely, and Not on Failure
The function form of invalidatesTags receives the error, which is what lets a failed mutation invalidate nothing.
export const todoMutations = api.injectEndpoints({
endpoints: (build) => ({
updateTodo: build.mutation<Todo, { id: string; patch: Partial<Todo> }>({
query: ({ id, patch }) => ({ url: `/todos/${id}`, method: 'PATCH', body: patch }),
// Nothing changed on the server, so invalidate nothing.
invalidatesTags: (_result, error, { id }) => (error ? [] : [{ type: 'Todo', id }]),
}),
createTodo: build.mutation<Todo, Partial<Todo>>({
query: (body) => ({ url: '/todos', method: 'POST', body }),
// Membership changed; no existing record did.
invalidatesTags: (_result, error) => (error ? [] : [{ type: 'Todo', id: 'LIST' }]),
}),
moveTodo: build.mutation<Todo, { id: string; fromBoard: string; toBoard: string }>({
query: ({ id, toBoard }) => ({ url: `/todos/${id}/move`, method: 'POST', body: { toBoard } }),
invalidatesTags: (_result, error, { id, fromBoard, toBoard }) =>
error
? []
: [
{ type: 'Todo', id },
{ type: 'Todo', id: 'LIST' },
{ type: 'Board', id: fromBoard },
{ type: 'Board', id: toBoard },
],
}),
}),
});
Cache Behavior Analysis: Invalidation runs after the mutation settles, and RTK Query refetches only the matched entries that currently have subscribers — matched entries without subscribers are flagged and fetch when something subscribes again, which is what stops a background invalidation from producing a burst of requests for screens nobody is on. Returning [] on error is a genuine correctness improvement rather than an optimisation: refetching after a failed write replaces the optimistic or existing state with server data that looks authoritative, which can mask the failure from the user. The four-tag moveTodo case shows the payoff of a precise vocabulary — the alternative of invalidating 'Todo' and 'Board' wholesale would refetch every cached board.
Step 4 — Combine Optimistic Patches With Invalidation
onQueryStarted applies an immediate patch; tags reconcile with the server afterwards. Both are needed, and the ordering matters.
updateTodoOptimistic: build.mutation<Todo, { id: string; patch: Partial<Todo> }>({
query: ({ id, patch }) => ({ url: `/todos/${id}`, method: 'PATCH', body: patch }),
invalidatesTags: (_result, error, { id }) => (error ? [] : [{ type: 'Todo', id }]),
async onQueryStarted({ id, patch }, { dispatch, queryFulfilled }) {
// Patch every cached view that holds this record.
const detailPatch = dispatch(
todoApi.util.updateQueryData('getTodo', id, (draft) => { Object.assign(draft, patch); }),
);
const listPatch = dispatch(
todoApi.util.updateQueryData('getTodos', {}, (draft) => {
const row = draft.find((todo) => todo.id === id);
if (row) Object.assign(row, patch);
}),
);
try {
await queryFulfilled;
// Success: invalidatesTags now refetches and replaces the patch with
// authoritative data. Nothing to undo.
} catch {
// Failure: undo both patches. invalidatesTags returned [] so no refetch
// will arrive to overwrite the restored values.
detailPatch.undo();
listPatch.undo();
}
},
}),
Cache Behavior Analysis: updateQueryData produces an Immer draft and returns a patch object whose undo() reverses exactly that change, rather than restoring a whole snapshot — so an unrelated update landing in between is preserved, which a snapshot-based rollback would silently discard. The interaction with invalidatesTags is the part that needs care: on success the invalidation refetches and the server’s value replaces the patch, and on failure the empty tag array means no refetch competes with the undo(). Patching the list and the detail separately is necessary because RTK Query’s cache is per endpoint and per argument, so there is no shared normalized entity that both views read — unlike Apollo, as described in Apollo InMemoryCache vs RTK Query Normalization.
Edge Cases & Gotchas
Tag ids are compared strictly. { type: 'Todo', id: 2 } does not match { type: 'Todo', id: '2' }. Normalize ids to strings at the boundary if your API returns numbers in some endpoints and strings in others.
invalidateTags from outside an endpoint. A socket message or a cross-tab broadcast can invalidate directly with dispatch(api.util.invalidateTags([{ type: 'Todo', id }])), which goes through the same registry as a mutation — useful for the realtime path in Handling Out-of-Order Realtime Updates.
keepUnusedDataFor versus invalidation. An entry already dropped for inactivity cannot be invalidated, so a short retention window makes invalidation look unreliable when it is actually the retention that is too aggressive.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| A mutation succeeds but the list does not update | Tag types differ, or the list provided no LIST sentinel |
Compare the exact strings; add the sentinel in providesTags |
| Retrying after an error changes nothing | The failed query provided no tags, so nothing can reach it | Return the collection tag on the error branch |
| A failed mutation refetches and hides the failure | invalidatesTags ignores the error argument |
Return [] when error is truthy |
| An optimistic patch reappears then disappears | The invalidation refetched while the undo was still pending | Do not invalidate on the error branch |
| Numeric ids never match | Tag ids are compared with strict equality | Normalize ids to strings at the API boundary |
Frequently Asked Questions
Why is my endpoint not refetching after a mutation?
In order of likelihood: the tag types do not match exactly, because matching is strict equality on both type and id, and there is no warning for a type that matches nothing; the query provided no tags on its error branch, so nothing can reach it; or the entry has no active subscribers, in which case it is marked for refetch rather than fetched now — which is correct behaviour and looks like a bug only if you are watching the network panel rather than the screen. Logging the resolved tag arrays from both sides for one mutation resolves it in about a minute.
Does invalidatesTags refetch endpoints nobody is rendering?
No, and this is one of the nicer properties of the model. An invalidated entry with active subscribers refetches immediately, so the screen updates; an invalidated entry with no subscribers is flagged and fetches only when something subscribes to it again, or is dropped entirely once keepUnusedDataFor elapses. That means a mutation can name a generous set of tags without generating a request per cached entry — the cost is proportional to what is on screen, not to what is in the cache.
Should a failed mutation still invalidate?
Usually not. Nothing changed on the server, so a refetch spends a request to confirm the state you already had — and worse, it replaces whatever the user is looking at with server data that carries no indication anything went wrong, which is how a failed save ends up looking like a successful one. The function form of invalidatesTags receives the error as its second argument precisely so you can return an empty array. The exception is a mutation whose failure mode can still leave partial server-side effects, where refetching is the honest way to find out what actually happened.
Related
- Tag-Based Invalidation Systems — the parent guide on tag-driven invalidation.
- Designing an Invalidation Tag Taxonomy — the vocabulary these endpoints implement.
- Implementing Tag-Based Invalidation in Apollo — the same model over a normalized cache.
- Apollo InMemoryCache vs RTK Query Normalization — why RTK Query needs a patch per view where Apollo needs one.