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:
- The specific post tag
{ type: 'Post', id: 123 }→ refetchesgetPostById(123) - The general
'Post'tag → refetchesgetPosts()
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
| Aspect | SWR | RTK Query |
|---|---|---|
| Cache key | URL string | Endpoint name + args |
| Invalidation | Manual mutate() | Automatic via tags |
| Granularity | Whole URL or custom key | Tag type + optional ID |
| Side effects | Custom promise handling | Built-in lifecycle hooks |
| Optimistic updates | Manual state mutation | Built-in updateQueryData |
| Rollback | Manual with promise catch | Automatic via undo() |
| Learning curve | Lower (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
onQueryStartedandupdateQueryDatamake 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
skipTokenand polling withpollingIntervalgive 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.