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 createApi from @reduxjs/toolkit/query/react.
  • A tag vocabulary decided in advance rather than grown per endpoint.
  • Endpoints whose responses include stable entity ids.
How RTK Query resolves an invalidation Query endpoints register the tags they provide against their cache entries. A mutation emits invalidated tags, which are matched against the registry by type and id. Matching entries with active subscribers refetch immediately; matching entries without subscribers are marked for refetch on next subscribe. Tags are a registry lookup, not a broadcast query endpoints getTodos → Todo:t1,t2,LIST getTodo(t2) → Todo:t2 providesTags runs per result tag registry tag → set of cache entries matched by type AND id strict equality, no coercion mutation updateTodo → Todo:t2 invalidatesTags runs after the request settles matched, has subscribers refetches immediately the screen updates without a reload matched, no subscribers marked for refetch, no request now fetches when something subscribes again A tag that matches nothing fails silently — there is no warning for a type that does not exist.
Invalidation is a registry lookup with strict equality. That is why a mismatched tag type produces no error and no refetch.

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.

What each endpoint provides and which mutations reach it Matrix of three query endpoints against three mutations, showing which mutations cause each endpoint to refetch. What each endpoint provides and which mutations reach it updateTodo(t2) createTodo() moveTodo(t2 → b9) getTodos() Refetches — provides Todo:t2 Refetches — provides Todo:LIST Refetches — both tags getTodo('t2') Refetches — provides Todo:t2 Untouched Refetches — provides Todo:t2 getBoardTodos('b1') Refetches — provides Todo:t2 Refetches — provides Todo:LIST Refetches — provides Board:b1 getBoardTodos('b7') Untouched unless it holds t2 Refetches — provides Todo:LIST Untouched
One tag per row plus a sentinel produces this table automatically. No endpoint needs to know about any mutation.

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.

Requests triggered by one updateTodo call Bar chart of requests issued. Invalidating the whole Todo type issues 27. Invalidating Todo:t2 issues 3. Invalidating Todo:t2 with an optimistic patch issues 3 but shows the change in 0 milliseconds. Invalidating on error as well issues 3 unnecessary extra requests. Requests triggered by one updateTodo call Cache holding 1 list, 2 board lists and 24 detail entries; 3 screens mounted invalidate the whole type 27 requests invalidate Todo:t2 3 requests Todo:t2 + optimistic patch 3 requests also invalidating on error 6 requests
Precision cuts the requests by an order of magnitude; the optimistic patch removes the wait entirely for the user.

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.