Day 5: RTK Query Basics
What You'll Learn Today
- What RTK Query is and why it replaces manual data fetching
- Setting up
createApiandfetchBaseQuery - Defining query endpoints (GET operations)
- Defining mutation endpoints (POST/PUT/DELETE)
- Auto-generated hooks:
useGetXQuery,useXMutation - Adding the API slice to the store with
middlewareandreducerPath - Automatic caching behavior and request deduplication
- Loading, error, and success states from hooks
What is RTK Query
RTK Query is a data fetching and caching solution built into Redux Toolkit. It dramatically simplifies the traditional Redux data fetching patterns.
flowchart LR
subgraph Before["Traditional Approach"]
A1["createAsyncThunk"] --> A2["extraReducers"]
A2 --> A3["Loading state mgmt"]
A3 --> A4["Error handling"]
A4 --> A5["Cache management"]
A5 --> A6["Re-fetch logic"]
end
subgraph After["RTK Query"]
B1["createApi"] --> B2["Auto-generated hooks"]
B2 --> B3["Everything managed automatically"]
end
style Before fill:#ef4444,color:#fff
style After fill:#22c55e,color:#fff
Why Use RTK Query
When fetching data with createAsyncThunk, you need a lot of boilerplate code.
// β Traditional approach: lots of boilerplate
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// Define thunks
const fetchPosts = createAsyncThunk('posts/fetchAll', async () => {
const response = await fetch('/api/posts');
return response.json();
});
const fetchPostById = createAsyncThunk('posts/fetchById', async (id) => {
const response = await fetch(`/api/posts/${id}`);
return response.json();
});
const createPost = createAsyncThunk('posts/create', async (newPost) => {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
});
return response.json();
});
// Define slice
const postsSlice = createSlice({
name: 'posts',
initialState: {
items: [],
currentPost: null,
loading: false,
error: null,
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchPosts.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
})
// Same pattern for fetchPostById, createPost...
},
});
TypeScript version
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
interface Post {
id: number;
title: string;
body: string;
}
interface PostsState {
items: Post[];
currentPost: Post | null;
loading: boolean;
error: string | null;
}
const fetchPosts = createAsyncThunk<Post[]>('posts/fetchAll', async () => {
const response = await fetch('/api/posts');
return response.json();
});
const fetchPostById = createAsyncThunk<Post, number>(
'posts/fetchById',
async (id) => {
const response = await fetch(`/api/posts/${id}`);
return response.json();
}
);
const createPost = createAsyncThunk<Post, Omit<Post, 'id'>>(
'posts/create',
async (newPost) => {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
});
return response.json();
}
);
const postsSlice = createSlice({
name: 'posts',
initialState: {
items: [],
currentPost: null,
loading: false,
error: null,
} as PostsState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchPosts.fulfilled, (state, action: PayloadAction<Post[]>) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchPosts.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message ?? 'Unknown error';
});
},
});
With RTK Query, all of this becomes dramatically simpler.
createApi and fetchBaseQuery
The core of RTK Query is the createApi function. You define a base URL and endpoints, and React hooks are auto-generated.
// features/api/postsApi.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const postsApi = createApi({
// Specify the reducer path in the store
reducerPath: 'postsApi',
// Configure the base URL
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com',
}),
// Define endpoints
endpoints: (builder) => ({
// Query and mutation endpoints go here
}),
});
TypeScript version
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com',
}),
endpoints: (builder) => ({
// endpoints will be defined here
}),
});
fetchBaseQuery Configuration Options
fetchBaseQuery is a wrapper around the fetch API with common configuration options.
const baseQuery = fetchBaseQuery({
baseUrl: 'https://api.example.com',
// Add headers to every request
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token;
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
},
// Timeout in milliseconds
timeout: 10000,
});
TypeScript version
import { fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { RootState } from '../../store';
const baseQuery = fetchBaseQuery({
baseUrl: 'https://api.example.com',
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.token;
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
},
timeout: 10000,
});
Defining Query Endpoints
Query endpoints are used for GET operations (fetching data). They are defined with builder.query.
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com',
}),
endpoints: (builder) => ({
// Fetch all posts
getPosts: builder.query({
query: () => '/posts',
}),
// Fetch a single post by ID
getPostById: builder.query({
query: (id) => `/posts/${id}`,
}),
// Fetch posts by user (with query parameter)
getPostsByUser: builder.query({
query: (userId) => `/posts?userId=${userId}`,
}),
}),
});
// Hooks are auto-generated
export const {
useGetPostsQuery,
useGetPostByIdQuery,
useGetPostsByUserQuery,
} = postsApi;
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',
}),
endpoints: (builder) => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts',
}),
getPostById: builder.query<Post, number>({
query: (id) => `/posts/${id}`,
}),
getPostsByUser: builder.query<Post[], number>({
query: (userId) => `/posts?userId=${userId}`,
}),
}),
});
export const {
useGetPostsQuery,
useGetPostByIdQuery,
useGetPostsByUserQuery,
} = postsApi;
Hook Naming Convention
RTK Query auto-generates hook names from endpoint names.
| Endpoint Name | Generated Hook |
|---|---|
getPosts |
useGetPostsQuery |
getPostById |
useGetPostByIdQuery |
createPost |
useCreatePostMutation |
updatePost |
useUpdatePostMutation |
deletePost |
useDeletePostMutation |
Defining Mutation Endpoints
Mutation endpoints are used for POST/PUT/DELETE operations (modifying data). They are defined with builder.mutation.
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com',
}),
endpoints: (builder) => ({
// GET: Fetch all posts
getPosts: builder.query({
query: () => '/posts',
}),
// GET: Fetch a single post
getPostById: builder.query({
query: (id) => `/posts/${id}`,
}),
// POST: Create a post
createPost: builder.mutation({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
}),
// PUT: Update a post
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: 'PUT',
body: patch,
}),
}),
// DELETE: Delete a post
deletePost: builder.mutation({
query: (id) => ({
url: `/posts/${id}`,
method: 'DELETE',
}),
}),
}),
});
export const {
useGetPostsQuery,
useGetPostByIdQuery,
useCreatePostMutation,
useUpdatePostMutation,
useDeletePostMutation,
} = postsApi;
TypeScript version
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
type NewPost = Omit<Post, 'id'>;
type UpdatePost = Partial<Post> & Pick<Post, 'id'>;
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com',
}),
endpoints: (builder) => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts',
}),
getPostById: builder.query<Post, number>({
query: (id) => `/posts/${id}`,
}),
createPost: builder.mutation<Post, NewPost>({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
}),
updatePost: builder.mutation<Post, UpdatePost>({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: 'PUT',
body: patch,
}),
}),
deletePost: builder.mutation<{ success: boolean }, number>({
query: (id) => ({
url: `/posts/${id}`,
method: 'DELETE',
}),
}),
}),
});
export const {
useGetPostsQuery,
useGetPostByIdQuery,
useCreatePostMutation,
useUpdatePostMutation,
useDeletePostMutation,
} = postsApi;
Adding the API Slice to the Store
The API slice created with createApi needs both a reducer and middleware added to the store.
// store.js
import { configureStore } from '@reduxjs/toolkit';
import { postsApi } from './features/api/postsApi';
export const store = configureStore({
reducer: {
// Add the API reducer (uses reducerPath as the key)
[postsApi.reducerPath]: postsApi.reducer,
},
// Add the API middleware (needed for caching, polling, etc.)
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(postsApi.middleware),
});
TypeScript version
import { configureStore } from '@reduxjs/toolkit';
import { postsApi } from './features/api/postsApi';
export const store = configureStore({
reducer: {
[postsApi.reducerPath]: postsApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(postsApi.middleware),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Why is Middleware Needed
The RTK Query middleware provides these essential features.
flowchart TB
subgraph Middleware["RTK Query Middleware"]
M1["Cache lifetime management"]
M2["Request deduplication"]
M3["Polling control"]
M4["Reference counting"]
M5["Automatic cleanup of unused cache"]
end
style Middleware fill:#8b5cf6,color:#fff
Automatic Caching Behavior
One of RTK Query's most powerful features is automatic caching. Duplicate requests for the same data are automatically deduplicated.
sequenceDiagram
participant C1 as Component A
participant C2 as Component B
participant RTK as RTK Query Cache
participant API as API Server
C1->>RTK: useGetPostsQuery()
RTK->>API: GET /posts
API-->>RTK: [posts data]
RTK-->>C1: Return data
Note over RTK: Stored in cache
C2->>RTK: useGetPostsQuery()
RTK-->>C2: Return from cache instantly
Note over C2: No API request!
Cache Lifecycle
// Component A mounts β data fetching begins
function PostList() {
const { data, isLoading } = useGetPostsQuery();
// Even if Component B uses the same query,
// no additional API request is made
}
// Component B uses the same data
function PostSidebar() {
const { data } = useGetPostsQuery();
// Data comes from cache (no API request)
}
The keepUnusedDataFor Option
By default, when all components using a piece of data unmount, the cache is removed after 60 seconds. This duration is configurable.
// Global configuration
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
// Applied to all endpoints
keepUnusedDataFor: 300, // 5 minutes
endpoints: (builder) => ({
getPosts: builder.query({
query: () => '/posts',
// Can also be set per endpoint
keepUnusedDataFor: 600, // 10 minutes
}),
}),
});
Hook Return Values: Loading, Error, and Success States
Query hooks return rich state information.
function PostList() {
const {
data, // The fetched data
error, // Error information
isLoading, // True during initial load
isFetching, // True during any fetch (including re-fetches)
isSuccess, // True when data is available
isError, // True when an error occurred
isUninitialized, // True before the request starts
refetch, // Function to manually re-fetch
} = useGetPostsQuery();
if (isLoading) return <p>Loading...</p>;
if (isError) return <p>Error: {error.message}</p>;
return (
<ul>
{data?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
TypeScript version
import { useGetPostsQuery } from './features/api/postsApi';
function PostList() {
const {
data,
error,
isLoading,
isFetching,
isSuccess,
isError,
refetch,
} = useGetPostsQuery();
if (isLoading) return <p>Loading...</p>;
if (isError) return <p>An error occurred</p>;
return (
<ul>
{data?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
isLoading vs isFetching
| State | isLoading | isFetching | Description |
|---|---|---|---|
| Initial load | true |
true |
No data in cache |
| Re-fetching | false |
true |
Data in cache, updating in background |
| Complete | false |
false |
Data fetched successfully |
function PostList() {
const { data, isLoading, isFetching } = useGetPostsQuery();
return (
<div>
{/* Show spinner only on initial load */}
{isLoading && <Spinner />}
{/* Dim content while re-fetching */}
<div style={{ opacity: isFetching ? 0.5 : 1 }}>
{data?.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</div>
);
}
Using Mutation Hooks
Mutation hooks differ from query hooks β they return a trigger function and a result object as a tuple.
function CreatePostForm() {
const [createPost, { isLoading, isSuccess, isError, error }] =
useCreatePostMutation();
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
try {
await createPost({ title, body, userId: 1 }).unwrap();
setTitle('');
setBody('');
alert('Post created successfully!');
} catch (err) {
console.error('Failed to create post:', err);
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
/>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Body"
/>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Creating...' : 'Create Post'}
</button>
{isError && <p>Error: {error.message}</p>}
</form>
);
}
TypeScript version
import { useState, FormEvent } from 'react';
import { useCreatePostMutation } from './features/api/postsApi';
function CreatePostForm() {
const [createPost, { isLoading, isError, error }] =
useCreatePostMutation();
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
try {
await createPost({ title, body, userId: 1 }).unwrap();
setTitle('');
setBody('');
alert('Post created successfully!');
} catch (err) {
console.error('Failed to create post:', err);
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
/>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Body"
/>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Creating...' : 'Create Post'}
</button>
{isError && <p>An error occurred</p>}
</form>
);
}
The unwrap() Method
unwrap() treats the mutation result as a Promise β it resolves with the data on success and throws on failure.
// Without unwrap()
const result = await createPost(newPost);
if ('data' in result) {
console.log('Success:', result.data);
} else {
console.log('Error:', result.error);
}
// With unwrap() (can use try/catch)
try {
const data = await createPost(newPost).unwrap();
console.log('Success:', data);
} catch (error) {
console.log('Error:', error);
}
Practical Example: Complete CRUD Application
Let's combine everything we've learned to build a full CRUD application for posts.
API Slice Definition
// features/api/postsApi.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com',
}),
endpoints: (builder) => ({
getPosts: builder.query({
query: () => '/posts',
}),
getPostById: builder.query({
query: (id) => `/posts/${id}`,
}),
createPost: builder.mutation({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
}),
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: 'PUT',
body: patch,
}),
}),
deletePost: builder.mutation({
query: (id) => ({
url: `/posts/${id}`,
method: 'DELETE',
}),
}),
}),
});
export const {
useGetPostsQuery,
useGetPostByIdQuery,
useCreatePostMutation,
useUpdatePostMutation,
useDeletePostMutation,
} = postsApi;
Post List Component
function PostList() {
const { data: posts, isLoading, isError, refetch } = useGetPostsQuery();
const [deletePost] = useDeletePostMutation();
const handleDelete = async (id) => {
if (window.confirm('Are you sure you want to delete this post?')) {
try {
await deletePost(id).unwrap();
alert('Post deleted');
} catch (err) {
alert('Failed to delete post');
}
}
};
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>An error occurred</div>;
return (
<div>
<h2>Posts</h2>
<button onClick={refetch}>Refresh</button>
{posts?.map((post) => (
<div key={post.id} style={{ border: '1px solid #ccc', padding: '16px', margin: '8px 0' }}>
<h3>{post.title}</h3>
<p>{post.body}</p>
<button onClick={() => handleDelete(post.id)}>Delete</button>
</div>
))}
</div>
);
}
Post Detail Component
function PostDetail({ postId }) {
const { data: post, isLoading, isError } = useGetPostByIdQuery(postId);
const [updatePost, { isLoading: isUpdating }] = useUpdatePostMutation();
const handleUpdate = async () => {
try {
await updatePost({
id: postId,
title: post.title + ' (updated)',
}).unwrap();
alert('Post updated');
} catch (err) {
alert('Failed to update post');
}
};
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Post not found</div>;
return (
<div>
<h2>{post.title}</h2>
<p>{post.body}</p>
<button onClick={handleUpdate} disabled={isUpdating}>
{isUpdating ? 'Updating...' : 'Update Title'}
</button>
</div>
);
}
Conditional Queries: The skip Option
When you want to skip a query under certain conditions, use the skip option.
function PostDetail({ postId }) {
// Skip the query when postId is undefined
const { data, isLoading } = useGetPostByIdQuery(postId, {
skip: !postId,
});
if (!postId) return <p>Please select a post</p>;
if (isLoading) return <p>Loading...</p>;
return <div>{data?.title}</div>;
}
createAsyncThunk vs RTK Query Comparison
| Feature | createAsyncThunk | RTK Query |
|---|---|---|
| Boilerplate | Heavy | Minimal |
| Cache management | Manual | Automatic |
| Loading states | Manual | Automatic |
| Request deduplication | None | Automatic |
| Re-fetching | Manual implementation | Simple refetch() |
| Data normalization | Manual | Automatic (optional) |
| TypeScript types | Manual definition | Auto-inferred |
| Learning curve | Low | Moderate |
| Best for | General-purpose logic | API communication |
Summary
Today we learned the fundamentals of RTK Query.
| Concept | Description |
|---|---|
createApi |
Function to create an API slice |
fetchBaseQuery |
fetch-based HTTP client wrapper |
builder.query |
Define GET request endpoints |
builder.mutation |
Define POST/PUT/DELETE endpoints |
| Auto-generated hooks | useXQuery, useXMutation |
| Automatic caching | Deduplicates identical requests |
isLoading |
True during initial load only |
isFetching |
True during any fetch including re-fetches |
unwrap() |
Promise-based error handling |
skip |
Conditional query execution |
Exercises
-
Basic: Create an API slice using
https://jsonplaceholder.typicode.com/usersto fetch a user list. Build a component that displays the user list using theuseGetUsersQueryhook. -
Intermediate: Create a comments CRUD API with the following endpoints:
GET /posts/:postId/comments- List comments for a postPOST /comments- Create a commentDELETE /comments/:id- Delete a comment
-
Challenge: Using the
skipoption, build a component with a dropdown to select a user. When a user is selected, display their posts. When no user is selected, do not send any request.