{post.title}
{post.content}
RTK Query is a powerful data fetching and caching library that is included with Redux Toolkit. It eliminates the need to hand-write data fetching and caching logic yourself.
Think of RTK Query as a smart TV compared to traditional cable:
Just as a smart TV manages content automatically, RTK Query manages your API data, caching, and updates automatically.
// actions/userActions.js
export const fetchUser = (id) => async (dispatch) => {
dispatch({ type: 'FETCH_USER_REQUEST' });
try {
const response = await api.get(`/users/${id}`);
dispatch({
type: 'FETCH_USER_SUCCESS',
payload: response.data
});
} catch (error) {
dispatch({
type: 'FETCH_USER_FAILURE',
payload: error.message
});
}
};
// reducers/userReducer.js
const userReducer = (state = initialState, action) => {
switch (action.type) {
case 'FETCH_USER_REQUEST':
return { ...state, loading: true, error: null };
case 'FETCH_USER_SUCCESS':
return {
...state,
loading: false,
user: action.payload
};
case 'FETCH_USER_FAILURE':
return {
...state,
loading: false,
error: action.payload
};
default:
return state;
}
};
// components/UserProfile.js
const UserProfile = ({ userId }) => {
const dispatch = useDispatch();
const { user, loading, error } = useSelector(
state => state.user
);
useEffect(() => {
dispatch(fetchUser(userId));
}, [dispatch, userId]);
if (loading) return Loading...;
if (error) return Error: {error};
if (!user) return null;
return {user.name};
};
// services/api.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: (builder) => ({
getUser: builder.query({
query: (id) => `users/${id}`,
}),
}),
});
export const { useGetUserQuery } = api;
// components/UserProfile.js
const UserProfile = ({ userId }) => {
const {
data: user,
isLoading,
error
} = useGetUserQuery(userId);
if (isLoading) return Loading...;
if (error) return Error: {error.message};
if (!user) return null;
return {user.name};
};
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
// Define our single API slice
export const apiSlice = createApi({
// The cache reducer expects to be added at `state.api`
reducerPath: 'api',
// All of our requests will have URLs starting with '/api'
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
// The "endpoints" represent operations and requests for this server
endpoints: (builder) => ({
// Query endpoints - for fetching data
getPosts: builder.query({
query: () => '/posts',
}),
getPost: builder.query({
query: (postId) => `/posts/${postId}`,
}),
// Mutation endpoints - for creating/updating/deleting data
addNewPost: builder.mutation({
query: (initialPost) => ({
url: '/posts',
method: 'POST',
body: initialPost,
}),
}),
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: 'PATCH',
body: patch,
}),
}),
}),
});
// Export hooks for usage in functional components
export const {
useGetPostsQuery,
useGetPostQuery,
useAddNewPostMutation,
useUpdatePostMutation,
} = apiSlice;
import { configureStore } from '@reduxjs/toolkit';
import { apiSlice } from './features/api/apiSlice';
export const store = configureStore({
reducer: {
// Add the generated reducer as a specific top-level slice
[apiSlice.reducerPath]: apiSlice.reducer,
// ... other reducers
},
// Adding the api middleware enables caching, invalidation, polling,
// and other useful features of `rtk-query`.
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(apiSlice.middleware),
});
// Basic query usage
const PostsList = () => {
const {
data: posts,
isLoading,
isSuccess,
isError,
error,
} = useGetPostsQuery();
if (isLoading) {
return Loading...;
}
if (isError) {
return Error: {error.message};
}
return (
{posts.map((post) => (
- {post.title}
))}
);
};
// Query with parameters
const SinglePost = ({ postId }) => {
const { data: post, isLoading } = useGetPostQuery(postId);
if (isLoading) return Loading...;
if (!post) return Post not found!;
return (
{post.title}
{post.content}
);
};
// Conditional fetching
const UserPosts = ({ userId }) => {
const { data: posts } = useGetPostsQuery(undefined, {
skip: !userId, // Skip the query if userId is not available
});
return posts ? <PostsList posts={posts} /> : null;
};
const AddPostForm = () => {
const [addNewPost, { isLoading }] = useAddNewPostMutation();
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
if (title && content) {
try {
await addNewPost({ title, content }).unwrap();
setTitle('');
setContent('');
} catch (error) {
console.error('Failed to save the post: ', error);
}
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Post Title"
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Post Content"
/>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save Post'}
</button>
</form>
);
};
// Mutation with optimistic updates
const EditablePostName = ({ post }) => {
const [updatePost, { isLoading }] = useUpdatePostMutation();
const [title, setTitle] = useState(post.title);
const handleChange = (e) => setTitle(e.target.value);
const handleBlur = () => {
if (title !== post.title) {
updatePost({ id: post.id, title });
}
};
return (
);
};
const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Post', 'User'],
endpoints: (builder) => ({
getPosts: builder.query({
query: () => '/posts',
// Provides a list of `Post` tags
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({ type: 'Post', id })),
{ type: 'Post', id: 'LIST' },
]
: [{ type: 'Post', id: 'LIST' }],
}),
getPost: builder.query({
query: (id) => `/posts/${id}`,
providesTags: (result, error, id) => [{ type: 'Post', id }],
}),
addPost: builder.mutation({
query: (body) => ({
url: '/posts',
method: 'POST',
body,
}),
// Invalidates all Post-type queries providing the `LIST` id
invalidatesTags: [{ type: 'Post', id: 'LIST' }],
}),
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: 'PATCH',
body: patch,
}),
// Invalidates all queries that provide this Post's 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 }],
}),
}),
});
const apiSlice = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: (builder) => ({
getPosts: builder.query({
query: () => '/posts',
transformResponse: (response) => {
// Sort posts by date
return response.sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
},
}),
getUsers: builder.query({
query: () => '/users',
transformResponse: (response) => {
// Transform array to object keyed by ID
return response.reduce((acc, user) => {
acc[user.id] = user;
return acc;
}, {});
},
}),
searchPosts: builder.query({
query: (searchTerm) => `/posts/search?q=${searchTerm}`,
transformResponse: (response, meta, arg) => {
// Add search term to each result
return response.map(post => ({
...post,
searchTerm: arg,
highlighted: post.title.includes(arg),
}));
},
}),
}),
});
// Polling for updates
const LatestPosts = () => {
const { data: posts } = useGetPostsQuery(undefined, {
pollingInterval: 3000, // Poll every 3 seconds
});
return ;
};
// Manual refetching
const RefreshablePosts = () => {
const { data: posts, refetch, isFetching } = useGetPostsQuery();
return (
);
};
// Automatic refetch on window focus
const AutoRefreshPosts = () => {
const { data: posts } = useGetPostsQuery(undefined, {
refetchOnFocus: true,
refetchOnReconnect: true,
});
return ;
};
const apiSlice = createApi({
baseQuery: fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers, { getState }) => {
// Get token from Redux state
const token = getState().auth.token;
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
},
}),
endpoints: (builder) => ({
// Custom query function
searchUsers: builder.query({
queryFn: async (searchTerm, _queryApi, _extraOptions, fetchWithBQ) => {
// Custom logic before the request
if (searchTerm.length < 3) {
return { data: [] };
}
// Use the base query function
const result = await fetchWithBQ(`/users/search?q=${searchTerm}`);
// Custom logic after the request
if (result.data) {
return {
data: result.data.filter(user => user.active),
};
}
return { error: result.error };
},
}),
// Query with custom cache time
getExpensiveData: builder.query({
query: () => '/expensive-data',
keepUnusedDataFor: 3600, // Keep in cache for 1 hour
}),
}),
});
// features/api/apiSlice.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token;
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
},
}),
tagTypes: ['Post', 'User', 'Comment'],
endpoints: (builder) => ({
// Posts
getPosts: builder.query({
query: (params = {}) => ({
url: '/posts',
params, // page, limit, category, etc.
}),
providesTags: (result = []) => [
'Post',
...result.map(({ id }) => ({ type: 'Post', id })),
],
transformResponse: (response, meta) => {
// Extract pagination info from headers
return {
posts: response,
totalCount: Number(meta.response.headers.get('X-Total-Count')),
};
},
}),
getPost: builder.query({
query: (id) => `/posts/${id}`,
providesTags: (result, error, id) => [{ type: 'Post', id }],
}),
createPost: builder.mutation({
query: (post) => ({
url: '/posts',
method: 'POST',
body: post,
}),
invalidatesTags: ['Post'],
}),
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
async onQueryStarted({ id, ...patch }, { dispatch, queryFulfilled }) {
// Optimistic update
const patchResult = dispatch(
apiSlice.util.updateQueryData('getPost', id, (draft) => {
Object.assign(draft, patch);
})
);
try {
await queryFulfilled;
} catch {
patchResult.undo();
}
},
}),
// Comments
getComments: builder.query({
query: (postId) => `/posts/${postId}/comments`,
providesTags: (result, error, postId) => [
{ type: 'Comment', id: `POST_${postId}` },
],
}),
addComment: builder.mutation({
query: ({ postId, content }) => ({
url: `/posts/${postId}/comments`,
method: 'POST',
body: { content },
}),
invalidatesTags: (result, error, { postId }) => [
{ type: 'Comment', id: `POST_${postId}` },
],
}),
// Users
getUsers: builder.query({
query: () => '/users',
providesTags: ['User'],
}),
getUser: builder.query({
query: (id) => `/users/${id}`,
providesTags: (result, error, id) => [{ type: 'User', id }],
}),
// Advanced: Infinite scrolling
getInfinitePosts: builder.query({
query: ({ page = 1, limit = 10 }) => `/posts?page=${page}&limit=${limit}`,
serializeQueryArgs: ({ endpointName }) => {
return endpointName;
},
merge: (currentCache, newItems) => {
currentCache.push(...newItems);
},
forceRefetch({ currentArg, previousArg }) {
return currentArg !== previousArg;
},
}),
}),
});
export const {
useGetPostsQuery,
useGetPostQuery,
useCreatePostMutation,
useUpdatePostMutation,
useGetCommentsQuery,
useAddCommentMutation,
useGetUsersQuery,
useGetUserQuery,
useGetInfinitePostsQuery,
} = apiSlice;
// components/PostsList.js
import { useState } from 'react';
import { useGetPostsQuery } from '../features/api/apiSlice';
const PostsList = () => {
const [page, setPage] = useState(1);
const {
data: { posts = [], totalCount = 0 } = {},
isLoading,
isFetching,
isError,
error,
} = useGetPostsQuery({ page, limit: 10 });
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error: {error.message}</div>;
return (
<div>
<h2>Posts {isFetching && <span>Refreshing...</span>}</h2>
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</li>
))}
</ul>
<div>
<button
onClick={() => setPage((p) => p - 1)}
disabled={page === 1}
>
Previous
</button>
<span>Page {page}</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={posts.length < 10}
>
Next
</button>
</div>
</div>
);
};
// Enhanced loading states
const PostsListWithStates = () => {
const {
data: posts,
isLoading,
isSuccess,
isError,
error,
isFetching,
isUninitialized,
} = useGetPostsQuery();
// Handle different states
if (isUninitialized) {
return Click to load posts;
}
if (isLoading) {
return <LoadingSpinner />;
}
if (isError) {
if (error.status === 404) {
return Posts not found;
}
if (error.status === 401) {
return Please login to view posts;
}
return Error: {error.data?.message || 'Unknown error'};
}
if (isSuccess) {
return (
{isFetching && Updating...}
);
}
return null;
};
// Handling mutation errors
const CreatePostForm = () => {
const [createPost, { isLoading, error }] = useCreatePostMutation();
const [formError, setFormError] = useState(null);
const handleSubmit = async (formData) => {
try {
setFormError(null);
await createPost(formData).unwrap();
// Success! Redirect or show success message
} catch (err) {
if (err.status === 400) {
setFormError(err.data.message || 'Invalid input');
} else {
setFormError('Failed to create post. Please try again.');
}
}
};
return (
);
};
// Test setup
import { renderHook } from '@testing-library/react-hooks';
import { Provider } from 'react-redux';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import { store } from './store';
import { useGetPostsQuery } from './apiSlice';
// Setup MSW server
const server = setupServer(
rest.get('/api/posts', (req, res, ctx) => {
return res(
ctx.json([
{ id: 1, title: 'Test Post' },
{ id: 2, title: 'Another Post' },
])
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Test hooks
test('useGetPostsQuery returns posts', async () => {
const wrapper = ({ children }) => (
{children}
);
const { result, waitForNextUpdate } = renderHook(
() => useGetPostsQuery(),
{ wrapper }
);
// Initial loading state
expect(result.current.isLoading).toBe(true);
expect(result.current.data).toBeUndefined();
// Wait for the query to resolve
await waitForNextUpdate();
// Success state
expect(result.current.isLoading).toBe(false);
expect(result.current.isSuccess).toBe(true);
expect(result.current.data).toHaveLength(2);
expect(result.current.data[0].title).toBe('Test Post');
});
// Test error handling
test('handles API errors', async () => {
server.use(
rest.get('/api/posts', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: 'Server error' }));
})
);
const wrapper = ({ children }) => (
{children}
);
const { result, waitForNextUpdate } = renderHook(
() => useGetPostsQuery(),
{ wrapper }
);
await waitForNextUpdate();
expect(result.current.isError).toBe(true);
expect(result.current.error.status).toBe(500);
});
// Test mutations
test('createPost mutation', async () => {
const wrapper = ({ children }) => (
{children}
);
server.use(
rest.post('/api/posts', (req, res, ctx) => {
return res(ctx.json({ id: 3, ...req.body }));
})
);
const { result, waitForNextUpdate } = renderHook(
() => useCreatePostMutation(),
{ wrapper }
);
const [createPost] = result.current;
// Trigger mutation
createPost({ title: 'New Post' });
await waitForNextUpdate();
expect(result.current[1].isSuccess).toBe(true);
expect(result.current[1].data).toEqual({
id: 3,
title: 'New Post',
});
});
Build a complete Todo API service with the following features:
// TODO: Create Todo API slice
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const todoApi = createApi({
reducerPath: 'todoApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Todo'],
endpoints: (builder) => ({
// TODO: Implement endpoints
getTodos: builder.query({
// Implementation here
}),
addTodo: builder.mutation({
// Implementation here
}),
updateTodo: builder.mutation({
// Implementation here
}),
deleteTodo: builder.mutation({
// Implementation here
}),
}),
});
// TODO: Export hooks
export const {
// Export hooks here
} = todoApi;
// TODO: Create components that use the API
const TodoList = () => {
// Implementation here
};
const AddTodoForm = () => {
// Implementation here
};
const TodoItem = ({ todo }) => {
// Implementation here
};