Skip to main content

RTK Query Advanced Caching Patterns

RTK Query's caching strategy revolves around tags: named identifiers that link queries to mutations. When a mutation fires, RTK Query invalidates all queries with matching tags, triggering automatic refetches. This pattern scales to hundreds of endpoints without manual cache management. Beyond tags, RTK Query supports optimistic updates (show results before the server confirms), conditional fetching, polling, and lifecycle hooks—giving you fine-grained control over data consistency and user experience.

Understanding Tags and Cache Invalidation

Tags are the cornerstone of RTK Query's cache. Define tag types in your API slice, then annotate which tags each endpoint provides and consumes:

export const api = createApi({
// ...
tagTypes: ['Post', 'Comment', 'User'],
endpoints: (builder) => ({
// Query: provides the 'Post' tag
getPosts: builder.query<Post[], void>({
query: () => '/posts',
providesTags: ['Post'],
}),

// Query: provides a specific post tag
getPostById: builder.query<Post, number>({
query: (id) => `/posts/${id}`,
providesTags: (result, error, id) => [{ type: 'Post', id }],
}),

// Mutation: invalidates both specific post and all posts
updatePost: builder.mutation<Post, { id: number; title: string }>({
query: ({ id, title }) => ({
url: `/posts/${id}`,
method: 'PATCH',
body: { title },
}),
invalidatesTags: (result, error, { id }) => [
{ type: 'Post', id },
'Post',
],
}),

// Mutation: creates a post, invalidates the list
createPost: builder.mutation<Post, { title: string }>({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
invalidatesTags: ['Post'],
}),
}),
});

When updatePost completes, RTK Query invalidates:

  1. The specific post tag { type: 'Post', id: 123 } → refetches getPostById(123)
  2. The general 'Post' tag → refetches getPosts()

This prevents inconsistency: editing a post updates both the detail view and the list.

Optimistic Updates with queryFulfilled

Optimistic updates show the user their change immediately, then sync with the server. RTK Query's queryFulfilled hook makes this safe:

export const api = createApi({
// ...
endpoints: (builder) => ({
updatePost: builder.mutation<Post, { id: number; title: string }>({
query: ({ id, title }) => ({
url: `/posts/${id}`,
method: 'PATCH',
body: { title },
}),
async onQueryStarted({ id, title }, { dispatch, queryFulfilled }) {
// Update the detail query optimistically
const patchResult1 = dispatch(
api.util.updateQueryData('getPostById', id, (draft) => {
draft.title = title;
})
);

// Update the list query optimistically
const patchResult2 = dispatch(
api.util.updateQueryData('getPosts', undefined, (draft) => {
const post = draft.find((p) => p.id === id);
if (post) post.title = title;
})
);

try {
// Wait for the mutation to complete
await queryFulfilled;
} catch (error) {
// Rollback on error
patchResult1.undo();
patchResult2.undo();
}
},
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
}),
}),
});

The user sees the updated title instantly. If the request fails, undo() rolls back the UI to the server state. This pattern makes mutations feel instant and resilient.

Conditional Fetching and Polling

Skip fetching until a condition is met by returning skipToken:

import { skipToken } from '@reduxjs/toolkit/query/react';

export function UserDetail({ userId }: { userId?: number }) {
const { data, isLoading } = api.useGetUserByIdQuery(
userId || skipToken // Skip fetch if userId is undefined
);

return <div>{data?.name}</div>;
}

Enable polling (periodic refetch) with pollingInterval:

export function LiveOrderStatus({ orderId }: { orderId: number }) {
const { data } = api.useGetOrderQuery(orderId, {
pollingInterval: 3000, // Refetch every 3 seconds while mounted
});

return <div>Status: {data?.status}</div>;
}

Lifecycle Hooks: onCacheEntryAdded, onQueryStarted, onError

Hook into the mutation lifecycle for side effects:

const api = createApi({
// ...
endpoints: (builder) => ({
deleteComment: builder.mutation<void, number>({
query: (commentId) => ({
url: `/comments/${commentId}`,
method: 'DELETE',
}),
async onQueryStarted(commentId, { dispatch, queryFulfilled }) {
// Show a toast when deletion starts
showToast(`Deleting comment ${commentId}...`);

try {
await queryFulfilled;
showToast('Comment deleted');
} catch (error) {
showToast(`Failed to delete: ${error.message}`);
}
},
invalidatesTags: ['Comment'],
}),
}),
});

For queries, onCacheEntryAdded fires when the cache entry is created:

getChatMessages: builder.query<Message[], number>({
query: (roomId) => `/chat/${roomId}`,
async onCacheEntryAdded(
roomId,
{ dispatch, cacheEntryRemoved, cacheDataLoaded }
) {
// Setup: subscribe to WebSocket
const socket = new WebSocket(`wss://api.example.com/chat/${roomId}`);

try {
await cacheDataLoaded;
// Initial data loaded; now listen for updates
socket.onmessage = (event) => {
dispatch(
api.util.updateQueryData('getChatMessages', roomId, (draft) => {
draft.push(JSON.parse(event.data));
})
);
};
} catch {
// Cleanup if the fetch failed
socket.close();
}

await cacheEntryRemoved;
// Cleanup when the cache entry is removed
socket.close();
},
providesTags: ['Message'],
}),

This pattern elegantly manages WebSocket subscriptions tied to component lifecycle.

Comparing Cache Invalidation: SWR vs RTK Query

AspectSWRRTK Query
Cache keyURL stringEndpoint name + args
InvalidationManual mutate()Automatic via tags
GranularityWhole URL or custom keyTag type + optional ID
Side effectsCustom promise handlingBuilt-in lifecycle hooks
Optimistic updatesManual state mutationBuilt-in updateQueryData
RollbackManual with promise catchAutomatic via undo()
Learning curveLower (imperative)Higher (declarative + Redux)

RTK Query's tags eliminate the entire category of "forgot to invalidate after mutation" bugs that plague SWR codebases.

Real-World Example: Todo App with Optimistic Updates

const api = createApi({
// ...
tagTypes: ['Todo'],
endpoints: (builder) => ({
getTodos: builder.query<Todo[], void>({
query: () => '/todos',
providesTags: ['Todo'],
}),

createTodo: builder.mutation<Todo, string>({
query: (text) => ({
url: '/todos',
method: 'POST',
body: { text },
}),
async onQueryStarted(text, { dispatch, queryFulfilled }) {
const optimisticTodo: Todo = {
id: Math.random(),
text,
completed: false,
};

const patchResult = dispatch(
api.util.updateQueryData('getTodos', undefined, (draft) => {
draft.unshift(optimisticTodo);
})
);

try {
const { data: serverTodo } = await queryFulfilled;
// Replace optimistic with real ID
dispatch(
api.util.updateQueryData('getTodos', undefined, (draft) => {
const index = draft.findIndex((t) => t.id === optimisticTodo.id);
if (index !== -1) draft[index] = serverTodo;
})
);
} catch {
patchResult.undo();
}
},
invalidatesTags: ['Todo'],
}),

toggleTodo: builder.mutation<Todo, number>({
query: (id) => ({
url: `/todos/${id}/toggle`,
method: 'PATCH',
}),
async onQueryStarted(id, { dispatch, queryFulfilled }) {
const patchResult = dispatch(
api.util.updateQueryData('getTodos', undefined, (draft) => {
const todo = draft.find((t) => t.id === id);
if (todo) todo.completed = !todo.completed;
})
);

try {
await queryFulfilled;
} catch {
patchResult.undo();
}
},
invalidatesTags: ['Todo'],
}),
}),
});

Key Takeaways

  • Tags link queries and mutations; when a mutation fires, RTK Query automatically refetches all queries with matching tags
  • Optimistic updates via onQueryStarted and updateQueryData make mutations instant and rollbackable
  • Lifecycle hooks (onCacheEntryAdded, onQueryStarted) manage side effects and subscriptions
  • RTK Query's explicit cache invalidation prevents "forgot to revalidate" bugs common in SWR
  • Conditional fetching with skipToken and polling with pollingInterval give fine-grained control

Frequently Asked Questions

Can I have tags with multiple levels of hierarchy?

Tags are flat—strings or { type, id } objects. For complex hierarchies (e.g., Organization.Department.User), use dot-notation in the tag name and invalidate strategically:

invalidatesTags: ['Organization.Department.User', 'Organization.Department'],

What happens if I invalidate a tag that no longer has queries?

Nothing. RTK Query tracks only active query subscriptions. If no component uses the tag, invalidating it is a no-op.

Can I invalidate a specific tag from outside a mutation?

Yes, using api.util.invalidateTags():

store.dispatch(
api.util.invalidateTags([{ type: 'Post', id: 123 }])
);

Does RTK Query support manual cache updates like SWR's mutate()?

Yes. Use api.util.updateQueryData() to patch the cache directly without a mutation endpoint.

How do I handle race conditions in optimistic updates?

RTK Query's queryFulfilled resolves only when the mutation succeeds. If two mutations race, their onQueryStarted handlers execute in parallel; RTK Query's Immer-based drafting handles concurrent updates safely.

Further Reading