Day 6: Advanced RTK Query
What You'll Learn Today
- Cache invalidation with tags (
providesTags,invalidatesTags) - Tag types and tag IDs for granular invalidation
- Optimistic updates with
onQueryStartedandupdateQueryData - Pessimistic update patterns
- Polling with
pollingInterval - Prefetching with
usePrefetch - Pagination patterns
- Transforming responses with
transformResponse - Custom
baseQueryfor authentication - Code splitting with
injectEndpoints
Cache Invalidation with Tags
The automatic caching we learned on Day 5 has one challenge: after modifying data (create, update, delete), the cached list data becomes stale. The tag system solves this problem.
sequenceDiagram
participant UI as UI Component
participant Cache as RTK Query Cache
participant API as API Server
UI->>Cache: useGetPostsQuery()
Cache->>API: GET /posts
API-->>Cache: posts data
Cache-->>UI: Return data
Note over Cache: Tag: ['Post'] assigned
UI->>Cache: useCreatePostMutation()
Cache->>API: POST /posts
API-->>Cache: new post
Note over Cache: Invalidate tag 'Post'
Cache->>API: GET /posts (auto re-fetch)
API-->>Cache: updated posts data
Cache-->>UI: Return fresh data
Basic Tag Configuration
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com',
}),
// Declare tag types used by this API
tagTypes: ['Post'],
endpoints: (builder) => ({
getPosts: builder.query({
query: () => '/posts',
// Tags this query provides
providesTags: ['Post'],
}),
getPostById: builder.query({
query: (id) => `/posts/${id}`,
// Provide a tag with a specific ID
providesTags: (result, error, id) => [{ type: 'Post', id }],
}),
createPost: builder.mutation({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
// Tags this mutation invalidates
invalidatesTags: ['Post'],
}),
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: 'PUT',
body: patch,
}),
// Only invalidate the specific ID
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
}),
deletePost: builder.mutation({
query: (id) => ({
url: `/posts/${id}`,
method: 'DELETE',
}),
invalidatesTags: (result, error, id) => [{ type: 'Post', id }],
}),
}),
});
TypeScript version
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com',
}),
tagTypes: ['Post'],
endpoints: (builder) => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts',
providesTags: ['Post'],
}),
getPostById: builder.query<Post, number>({
query: (id) => `/posts/${id}`,
providesTags: (result, error, id) => [{ type: 'Post', id }],
}),
createPost: builder.mutation<Post, Omit<Post, 'id'>>({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
invalidatesTags: ['Post'],
}),
updatePost: builder.mutation<Post, Partial<Post> & Pick<Post, 'id'>>({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: 'PUT',
body: patch,
}),
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
}),
deletePost: builder.mutation<void, number>({
query: (id) => ({
url: `/posts/${id}`,
method: 'DELETE',
}),
invalidatesTags: (result, error, id) => [{ type: 'Post', id }],
}),
}),
});
Granular Invalidation with Tag IDs
By assigning IDs to tags, you can re-fetch only specific items rather than the entire list.
flowchart TB
subgraph Tags["Tag Structure"]
T1["{ type: 'Post', id: 'LIST' }"]
T2["{ type: 'Post', id: 1 }"]
T3["{ type: 'Post', id: 2 }"]
T4["{ type: 'Post', id: 3 }"]
end
subgraph Invalidation["Invalidation Patterns"]
I1["Invalidate 'Post'<br/>β All Post tags invalidated"]
I2["Invalidate { type: 'Post', id: 1 }<br/>β Only ID:1 re-fetched"]
I3["Invalidate { type: 'Post', id: 'LIST' }<br/>β Only list re-fetched"]
end
style Tags fill:#3b82f6,color:#fff
style Invalidation fill:#f59e0b,color:#fff
Advanced Tag Patterns
endpoints: (builder) => ({
getPosts: builder.query({
query: () => '/posts',
// Provide individual tags for each post plus a list tag
providesTags: (result) =>
result
? [
// Individual tag for each post
...result.map(({ id }) => ({ type: 'Post', id })),
// Tag for the entire list
{ type: 'Post', id: 'LIST' },
]
: [{ type: 'Post', id: 'LIST' }],
}),
createPost: builder.mutation({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
// On create, only re-fetch the list
invalidatesTags: [{ type: 'Post', id: 'LIST' }],
}),
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: 'PUT',
body: patch,
}),
// On update, only re-fetch the specific ID
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
}),
deletePost: builder.mutation({
query: (id) => ({
url: `/posts/${id}`,
method: 'DELETE',
}),
// On delete, invalidate both the item and the list
invalidatesTags: (result, error, id) => [
{ type: 'Post', id },
{ type: 'Post', id: 'LIST' },
],
}),
}),
TypeScript version
endpoints: (builder) => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts',
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({ type: 'Post' as const, id })),
{ type: 'Post' as const, id: 'LIST' },
]
: [{ type: 'Post' as const, id: 'LIST' }],
}),
createPost: builder.mutation<Post, Omit<Post, 'id'>>({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
invalidatesTags: [{ type: 'Post', id: 'LIST' }],
}),
updatePost: builder.mutation<Post, Partial<Post> & Pick<Post, 'id'>>({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: 'PUT',
body: patch,
}),
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
}),
deletePost: builder.mutation<void, number>({
query: (id) => ({
url: `/posts/${id}`,
method: 'DELETE',
}),
invalidatesTags: (result, error, id) => [
{ type: 'Post', id },
{ type: 'Post', id: 'LIST' },
],
}),
}),
Multiple Tag Types
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Post', 'Comment', 'User'],
endpoints: (builder) => ({
getPostWithComments: builder.query({
query: (postId) => `/posts/${postId}?_embed=comments`,
// Provide multiple tag types
providesTags: (result, error, postId) => [
{ type: 'Post', id: postId },
{ type: 'Comment', id: `POST_${postId}` },
],
}),
addComment: builder.mutation({
query: ({ postId, ...comment }) => ({
url: '/comments',
method: 'POST',
body: { postId, ...comment },
}),
// Invalidate comments for the related post
invalidatesTags: (result, error, { postId }) => [
{ type: 'Comment', id: `POST_${postId}` },
],
}),
}),
});
Optimistic Updates
Optimistic updates instantly update the UI without waiting for the server response. This dramatically improves the user experience.
sequenceDiagram
participant UI as UI
participant Cache as Cache
participant API as API
UI->>Cache: Toggle todo completion
Cache->>UI: Update UI instantly (optimistic)
Cache->>API: PUT /todos/1
alt Success
API-->>Cache: 200 OK
Note over Cache: Cache already up to date
else Failure
API-->>Cache: Error
Cache->>UI: Revert to original state (rollback)
end
Implementing Optimistic Updates
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Todo'],
endpoints: (builder) => ({
getTodos: builder.query({
query: () => '/todos',
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({ type: 'Todo', id })),
{ type: 'Todo', id: 'LIST' },
]
: [{ type: 'Todo', id: 'LIST' }],
}),
toggleTodo: builder.mutation({
query: ({ id, completed }) => ({
url: `/todos/${id}`,
method: 'PATCH',
body: { completed },
}),
// Optimistic update
async onQueryStarted({ id, completed }, { dispatch, queryFulfilled }) {
// 1. Immediately update the cache
const patchResult = dispatch(
api.util.updateQueryData('getTodos', undefined, (draft) => {
const todo = draft.find((t) => t.id === id);
if (todo) {
todo.completed = completed;
}
})
);
try {
// 2. Wait for the server response
await queryFulfilled;
} catch {
// 3. Rollback on error
patchResult.undo();
}
},
}),
}),
});
TypeScript version
interface Todo {
id: number;
title: string;
completed: boolean;
userId: number;
}
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Todo'],
endpoints: (builder) => ({
getTodos: builder.query<Todo[], void>({
query: () => '/todos',
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({ type: 'Todo' as const, id })),
{ type: 'Todo' as const, id: 'LIST' },
]
: [{ type: 'Todo' as const, id: 'LIST' }],
}),
toggleTodo: builder.mutation<Todo, { id: number; completed: boolean }>({
query: ({ id, completed }) => ({
url: `/todos/${id}`,
method: 'PATCH',
body: { completed },
}),
async onQueryStarted({ id, completed }, { dispatch, queryFulfilled }) {
const patchResult = dispatch(
api.util.updateQueryData('getTodos', undefined, (draft) => {
const todo = draft.find((t) => t.id === id);
if (todo) {
todo.completed = completed;
}
})
);
try {
await queryFulfilled;
} catch {
patchResult.undo();
}
},
}),
}),
});
Todo Component with Optimistic Updates
function TodoList() {
const { data: todos } = useGetTodosQuery();
const [toggleTodo] = useToggleTodoMutation();
return (
<ul>
{todos?.map((todo) => (
<li
key={todo.id}
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
cursor: 'pointer',
}}
onClick={() =>
toggleTodo({ id: todo.id, completed: !todo.completed })
}
>
{todo.title}
</li>
))}
</ul>
);
}
Pessimistic Updates
Pessimistic updates wait for the server response before updating the cache. Use this when data integrity is critical.
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Post'],
endpoints: (builder) => ({
getPosts: builder.query({
query: () => '/posts',
providesTags: ['Post'],
}),
createPost: builder.mutation({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
// Pessimistic update: update cache after server responds
async onQueryStarted(newPost, { dispatch, queryFulfilled }) {
try {
const { data: createdPost } = await queryFulfilled;
// Update cache with server-returned data
dispatch(
api.util.updateQueryData('getPosts', undefined, (draft) => {
draft.push(createdPost);
})
);
} catch {
// No action needed β cache was never modified
}
},
}),
}),
});
TypeScript version
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Post'],
endpoints: (builder) => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts',
providesTags: ['Post'],
}),
createPost: builder.mutation<Post, Omit<Post, 'id'>>({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
async onQueryStarted(newPost, { dispatch, queryFulfilled }) {
try {
const { data: createdPost } = await queryFulfilled;
dispatch(
api.util.updateQueryData('getPosts', undefined, (draft) => {
draft.push(createdPost);
})
);
} catch {
// No rollback needed
}
},
}),
}),
});
When to Use Optimistic vs Pessimistic Updates
| Strategy | UI Responsiveness | Data Integrity | Best For |
|---|---|---|---|
| Optimistic | Instant | Risk of rollback | Likes, toggles, reordering |
| Pessimistic | Slower (waits) | Safe | Payments, critical data creation |
| Tag invalidation | Slower (re-fetch) | Safe | General CRUD operations |
Polling
Use pollingInterval to automatically re-fetch data at regular intervals. This is useful for real-time dashboards.
function LiveDashboard() {
// Auto-refresh data every 30 seconds
const { data: stats, isLoading } = useGetDashboardStatsQuery(undefined, {
pollingInterval: 30000, // 30 seconds
});
if (isLoading) return <div>Loading...</div>;
return (
<div>
<h2>Dashboard</h2>
<div>Active Users: {stats?.activeUsers}</div>
<div>Today's Revenue: ${stats?.todayRevenue?.toLocaleString()}</div>
<div>Orders: {stats?.orderCount}</div>
</div>
);
}
Controlling Polling
function PollingControl() {
const [pollingInterval, setPollingInterval] = useState(0);
const { data } = useGetNotificationsQuery(undefined, {
// Passing 0 stops polling
pollingInterval,
// Skip polling when the window is not focused
skipPollingIfUnfocused: true,
});
return (
<div>
<button onClick={() => setPollingInterval(5000)}>Every 5s</button>
<button onClick={() => setPollingInterval(30000)}>Every 30s</button>
<button onClick={() => setPollingInterval(0)}>Stop Polling</button>
</div>
);
}
Complete Real-Time Dashboard Example
// features/api/dashboardApi.js
const dashboardApi = createApi({
reducerPath: 'dashboardApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Stats'],
endpoints: (builder) => ({
getDashboardStats: builder.query({
query: () => '/dashboard/stats',
providesTags: ['Stats'],
}),
getRecentOrders: builder.query({
query: () => '/dashboard/orders/recent',
providesTags: ['Stats'],
}),
getAlerts: builder.query({
query: () => '/dashboard/alerts',
}),
}),
});
export const {
useGetDashboardStatsQuery,
useGetRecentOrdersQuery,
useGetAlertsQuery,
} = dashboardApi;
// Dashboard.jsx
function Dashboard() {
const { data: stats } = useGetDashboardStatsQuery(undefined, {
pollingInterval: 30000,
});
const { data: orders } = useGetRecentOrdersQuery(undefined, {
pollingInterval: 10000,
});
const { data: alerts } = useGetAlertsQuery(undefined, {
pollingInterval: 5000,
});
return (
<div>
<StatsCards stats={stats} />
<OrderTable orders={orders} />
<AlertBanner alerts={alerts} />
</div>
);
}
Prefetching
Use usePrefetch to fetch data before the user needs it.
function PostList() {
const { data: posts } = useGetPostsQuery();
// Get the prefetch function
const prefetchPost = usePrefetch('getPostById');
return (
<ul>
{posts?.map((post) => (
<li
key={post.id}
// Prefetch on mouse hover
onMouseEnter={() => prefetchPost(post.id)}
>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</li>
))}
</ul>
);
}
Prefetch Options
function PostList() {
const prefetchPost = usePrefetch('getPostById', {
// Only prefetch if data is older than 60 seconds
ifOlderThan: 60,
});
// Or use force to always prefetch
const prefetchPostForce = usePrefetch('getPostById', {
force: true,
});
return (
<ul>
{posts?.map((post) => (
<li
key={post.id}
onMouseEnter={() => prefetchPost(post.id)}
onFocus={() => prefetchPost(post.id)}
>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</li>
))}
</ul>
);
}
Pagination
Here is a pattern for implementing pagination with RTK Query.
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Post'],
endpoints: (builder) => ({
getPosts: builder.query({
query: ({ page = 1, limit = 10 }) =>
`/posts?_page=${page}&_limit=${limit}`,
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({ type: 'Post', id })),
{ type: 'Post', id: 'PARTIAL-LIST' },
]
: [{ type: 'Post', id: 'PARTIAL-LIST' }],
}),
}),
});
Pagination Component
function PaginatedPosts() {
const [page, setPage] = useState(1);
const { data: posts, isLoading, isFetching } = useGetPostsQuery({
page,
limit: 10,
});
const prefetchPosts = usePrefetch('getPosts');
return (
<div>
{isLoading ? (
<div>Loading...</div>
) : (
<div style={{ opacity: isFetching ? 0.5 : 1 }}>
{posts?.map((post) => (
<div key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</div>
))}
</div>
)}
<div>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
Previous
</button>
<span>Page {page}</span>
<button
onClick={() => setPage((p) => p + 1)}
// Prefetch the next page
onMouseEnter={() =>
prefetchPosts({ page: page + 1, limit: 10 })
}
>
Next
</button>
</div>
</div>
);
}
transformResponse
You can transform the server response before it is stored in the cache.
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: (builder) => ({
// Extract only needed fields
getUsers: builder.query({
query: () => '/users',
transformResponse: (response) =>
response.map((user) => ({
id: user.id,
name: user.name,
email: user.email,
})),
}),
// Enrich response data
getPostComments: builder.query({
query: (postId) => `/posts/${postId}/comments`,
transformResponse: (response) =>
response.map((comment) => ({
...comment,
shortBody: comment.body.substring(0, 100),
})),
}),
// Sort or normalize
getSortedPosts: builder.query({
query: () => '/posts',
transformResponse: (response) =>
[...response].sort((a, b) =>
a.title.localeCompare(b.title)
),
}),
}),
});
TypeScript version
interface UserRaw {
id: number;
name: string;
email: string;
phone: string;
website: string;
company: { name: string };
address: { city: string };
}
interface UserSimple {
id: number;
name: string;
email: string;
}
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: (builder) => ({
getUsers: builder.query<UserSimple[], void>({
query: () => '/users',
transformResponse: (response: UserRaw[]) =>
response.map((user) => ({
id: user.id,
name: user.name,
email: user.email,
})),
}),
}),
});
Custom baseQuery
Use a custom baseQuery for shared authentication and error handling logic.
import { fetchBaseQuery } from '@reduxjs/toolkit/query/react';
// baseQuery with token refresh
const baseQuery = fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.accessToken;
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
},
});
const baseQueryWithReauth = async (args, api, extraOptions) => {
let result = await baseQuery(args, api, extraOptions);
if (result?.error?.status === 401) {
// Attempt to refresh the token
const refreshResult = await baseQuery(
{ url: '/auth/refresh', method: 'POST' },
api,
extraOptions
);
if (refreshResult?.data) {
// Store the new token
api.dispatch(setCredentials(refreshResult.data));
// Retry the original request
result = await baseQuery(args, api, extraOptions);
} else {
// Refresh failed β log out
api.dispatch(logout());
}
}
return result;
};
export const api = createApi({
baseQuery: baseQueryWithReauth,
tagTypes: ['Post', 'User'],
endpoints: () => ({}),
});
TypeScript version
import {
fetchBaseQuery,
BaseQueryFn,
FetchArgs,
FetchBaseQueryError,
} from '@reduxjs/toolkit/query/react';
import { RootState } from '../../store';
import { setCredentials, logout } from '../auth/authSlice';
const baseQuery = fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.accessToken;
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
},
});
const baseQueryWithReauth: BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError
> = async (args, api, extraOptions) => {
let result = await baseQuery(args, api, extraOptions);
if (result?.error?.status === 401) {
const refreshResult = await baseQuery(
{ url: '/auth/refresh', method: 'POST' },
api,
extraOptions
);
if (refreshResult?.data) {
api.dispatch(setCredentials(refreshResult.data as { accessToken: string }));
result = await baseQuery(args, api, extraOptions);
} else {
api.dispatch(logout());
}
}
return result;
};
export const api = createApi({
baseQuery: baseQueryWithReauth,
tagTypes: ['Post', 'User'],
endpoints: () => ({}),
});
Code Splitting with injectEndpoints
In large applications, defining all endpoints in a single file is impractical. Use injectEndpoints to split endpoints across files.
// features/api/baseApi.js - Shared API definition
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const baseApi = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Post', 'User', 'Comment'],
endpoints: () => ({}),
});
// features/api/postsApi.js - Post endpoints
import { baseApi } from './baseApi';
export const postsApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getPosts: builder.query({
query: () => '/posts',
providesTags: ['Post'],
}),
createPost: builder.mutation({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
invalidatesTags: ['Post'],
}),
}),
});
export const { useGetPostsQuery, useCreatePostMutation } = postsApi;
// features/api/usersApi.js - User endpoints
import { baseApi } from './baseApi';
export const usersApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getUsers: builder.query({
query: () => '/users',
providesTags: ['User'],
}),
getUserById: builder.query({
query: (id) => `/users/${id}`,
providesTags: (result, error, id) => [{ type: 'User', id }],
}),
}),
});
export const { useGetUsersQuery, useGetUserByIdQuery } = usersApi;
Store Configuration Uses Only baseApi
// store.js
import { configureStore } from '@reduxjs/toolkit';
import { baseApi } from './features/api/baseApi';
export const store = configureStore({
reducer: {
[baseApi.reducerPath]: baseApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(baseApi.middleware),
});
Summary
Today we learned advanced RTK Query techniques.
| Concept | Description |
|---|---|
providesTags |
Cache tags a query provides |
invalidatesTags |
Tags a mutation invalidates |
| Tag IDs | Enable granular cache invalidation |
| Optimistic updates | Update UI instantly before server responds |
| Pessimistic updates | Update cache after server confirms |
pollingInterval |
Automatic periodic data re-fetching |
usePrefetch |
Pre-fetch data before user needs it |
transformResponse |
Transform server responses before caching |
Custom baseQuery |
Shared auth and error handling |
injectEndpoints |
Split endpoints across files |
Exercises
-
Basic: Create an API slice with two tag types β Post and Comment β where adding a comment only re-fetches the comments for the related post, not all posts.
-
Intermediate: Implement optimistic updates for a todo app. When editing a todo's title, update the UI immediately and roll back if the server returns an error.
-
Challenge: Implement a custom
baseQuerywith automatic token refresh. On a 401 error, use a refresh token to obtain a new access token and retry the original request. Also split the endpoints across multiple files usinginjectEndpoints.