π‘ RTK Query Basics
Most of what a slice-plus-thunk does for network data is the same every time: fire a request, track loading, store the result, handle errors, avoid refetching what you already have. RTK Query is the Redux team's answer β a data-fetching and caching layer that writes all of that for you. You describe your endpoints once and get back React hooks, a cache, and automatic re-fetching for free.
Week 6 · Thursday: Redux Toolkit · Lecture 3
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain what RTK Query automates and when to prefer it over hand-written thunks
- Define an API slice with
createApiandfetchBaseQuery - Distinguish query endpoints (reads) from mutation endpoints (writes)
- Use the auto-generated
useXxxQueryanduseXxxMutationhooks and their status flags - Register the API reducer and middleware in
configureStore - Keep the UI in sync automatically with
providesTagsandinvalidatesTags
Estimated Time: 70 minutes
Practice: Build a posts API slice with list/detail queries and a create mutation that refreshes the list.
In This Lesson
What is RTK Query?
RTK Query is a data-fetching and caching solution built into Redux Toolkit. It is purpose-built for server state: data that lives on a server, that you fetch, cache, and keep fresh. It generates the thunks, the reducer, the cache, and the React hooks β so you stop hand-writing that machinery entirely.
π‘ The Streaming Service Analogy
Hand-written fetching is like ripping DVDs: you manually download each show, name the files, track which episodes you have, and re-download when something changes. RTK Query is a streaming app β you just say "play this," and it fetches, caches, remembers what you've watched, and quietly updates in the background.
- Endpoints β the catalog of things you can request
- Cache β episodes kept ready so you don't re-download
- Tags & invalidation β the "new episode available" signal that refreshes what's stale
Under the hood it's still Redux Toolkit β createAsyncThunk and a slice you never have to write. You get a thin declarative layer on top.
Before & After
Compare the amount of code needed to fetch and display one user. The manual version is everything you built last lesson; the RTK Query version is a few lines.
Before β manual thunk + reducer + component
// thunk
export const fetchUser = (id) => async (dispatch) => {
dispatch({ type: 'FETCH_USER_REQUEST' });
try {
const res = await api.get(`/users/${id}`);
dispatch({ type: 'FETCH_USER_SUCCESS', payload: res.data });
} catch (err) {
dispatch({ type: 'FETCH_USER_FAILURE', payload: err.message });
}
};
// reducer (omitted β the usual switch with loading/error/user)
// component
const UserProfile = ({ userId }) => {
const dispatch = useDispatch();
const { user, loading, error } = useSelector((s) => s.user);
useEffect(() => { dispatch(fetchUser(userId)); }, [dispatch, userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user.name}</div>;
};
After β one endpoint, one hook
// 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; // hook generated automatically
// component
const UserProfile = ({ userId }) => {
const { data: user, isLoading, error } = useGetUserQuery(userId);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading user</div>;
return <div>{user.name}</div>;
};
β Everything the second version gives you free
The thunk, the reducer, the cache, deduped requests (two components asking for the same user share one fetch), and the isLoading / error flags β all generated from that single getUser endpoint.
Creating an API Slice
You define one API slice per base URL with createApi. It takes a reducerPath (where its cache lives in the store), a baseQuery (how requests are made), and an endpoints builder describing each operation.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const apiSlice = createApi({
reducerPath: 'api', // state.api holds the cache
baseQuery: fetchBaseQuery({ baseUrl: '/api' }), // a thin fetch wrapper
tagTypes: ['Post'], // cache tag names (see below)
endpoints: (builder) => ({
// QUERY endpoints β read data
getPosts: builder.query({
query: () => '/posts'
}),
getPost: builder.query({
query: (postId) => `/posts/${postId}`
}),
// MUTATION endpoints β write 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
})
})
})
});
// Hooks are generated from endpoint names: use + Name + Query/Mutation
export const {
useGetPostsQuery,
useGetPostQuery,
useAddNewPostMutation,
useUpdatePostMutation
} = apiSlice;
π‘ fetchBaseQuery
A lightweight wrapper around the browser fetch API. Set a baseUrl once and each endpoint's query returns just the path (a string) or a request object ({ url, method, body }). You can also inject headers, e.g. an auth token, via prepareHeaders.
Store Setup
The API slice generates both a reducer and a middleware. Add the reducer under its reducerPath, and concat the middleware onto the defaults β the middleware is what powers caching, invalidation, and polling.
import { configureStore } from '@reduxjs/toolkit';
import { apiSlice } from './features/api/apiSlice';
export const store = configureStore({
reducer: {
[apiSlice.reducerPath]: apiSlice.reducer, // β state.api
// ...your other slice reducers
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(apiSlice.middleware)
});
β οΈ Don't forget the middleware
If you add the reducer but forget .concat(apiSlice.middleware), queries still run once but caching, tag invalidation, re-fetching, and polling silently stop working. It's the single most common RTK Query setup mistake.
Query Endpoints & Hooks
Each query endpoint generates a hook. Call it in a component and it returns the data plus a rich set of status flags. The hook automatically starts the fetch on mount and subscribes the component to that cache entry.
| Field | Meaning |
|---|---|
data | The cached response (undefined until it arrives) |
isLoading | true only on the first load, when there's no cached data yet |
isFetching | true whenever a request is in flight, including background re-fetches |
isSuccess / isError | Terminal status flags |
error | The error object when isError |
refetch() | Manually trigger a re-fetch |
Basic query
const PostsList = () => {
const { data: posts, isLoading, isError, error } = useGetPostsQuery();
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error: {error.status}</div>;
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
};
Query with an argument
Whatever you pass to the hook becomes the argument to the endpoint's query function β and part of the cache key, so each id is cached separately.
const SinglePost = ({ postId }) => {
const { data: post, isLoading } = useGetPostQuery(postId);
if (isLoading) return <div>Loading...</div>;
return (
<article>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
);
};
Conditional fetching with skip
const UserPosts = ({ userId }) => {
// Don't fire the query until we actually have a userId
const { data: posts } = useGetPostsQuery(userId, { skip: !userId });
return posts ? <PostsList posts={posts} /> : null;
};
π‘ isLoading vs isFetching
Use isLoading to show a full-page spinner on the very first load. Use isFetching to show a subtle "refreshingβ¦" indicator during a background re-fetch, while the stale data stays visible. Reaching for isLoading on re-fetches makes the UI flash empty.
Mutation Endpoints
Mutations change server data (POST/PATCH/DELETE). Their hook returns a tuple: a trigger function you call to fire the request, and a result object with status flags.
const AddPostForm = () => {
const [addNewPost, { isLoading }] = useAddNewPostMutation();
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
try {
// .unwrap() throws on failure so try/catch works
await addNewPost({ title, content }).unwrap();
setTitle('');
setContent('');
} catch (err) {
console.error('Failed to save the post:', err);
}
};
return (
<form onSubmit={handleSubmit}>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title" />
<textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder="Content" />
<button type="submit" disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save Post'}
</button>
</form>
);
};
The shape of a mutation hook
const [trigger, result] = useAddNewPostMutation();
// trigger(arg) β fires the request, returns a promise (.unwrap() available)
// result.isLoading β request in flight
// result.isSuccess β completed OK
// result.error β set on failure
Practice & Quiz
ποΈ Exercise 1: A todos API slice
Goal: Create an API slice with a getTodos query and an addTodo mutation, wired so adding a todo refreshes the list automatically.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const todoApi = createApi({
reducerPath: 'todoApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Todo'],
endpoints: (builder) => ({
// TODO: getTodos query (provides Todo/LIST)
// TODO: addTodo mutation (invalidates Todo/LIST)
})
});
// TODO: export the generated hooks
π‘ Hint
The query providesTags: [{ type: 'Todo', id: 'LIST' }]; the mutation invalidatesTags: [{ type: 'Todo', id: 'LIST' }]. Hooks are useGetTodosQuery and useAddTodoMutation.
β Solution
endpoints: (builder) => ({
getTodos: builder.query({
query: () => '/todos',
providesTags: [{ type: 'Todo', id: 'LIST' }]
}),
addTodo: builder.mutation({
query: (todo) => ({ url: '/todos', method: 'POST', body: todo }),
invalidatesTags: [{ type: 'Todo', id: 'LIST' }]
})
})
export const { useGetTodosQuery, useAddTodoMutation } = todoApi;
ποΈ Exercise 2: Fix the flashing spinner
Goal: This list flashes an empty "Loading..." every time it re-fetches in the background. Change one flag so the stale list stays visible during re-fetches.
const { data: posts, isLoading } = useGetPostsQuery();
if (isLoading) return <Spinner />; // fires on every background re-fetch too
β Solution
Keep isLoading for the true first load, and use isFetching only for a subtle inline indicator β never blank the screen on a re-fetch.
const { data: posts, isLoading, isFetching } = useGetPostsQuery();
if (isLoading) return <Spinner />; // first load only
return (
<div>
{isFetching && <small>Refreshing...</small>}
<PostsList posts={posts} />
</div>
);
π― Quick Quiz
Question 1: What is the difference between a query and a mutation endpoint?
Question 2: You added the API reducer but the cache never invalidates. What's missing?
Question 3: A mutation's invalidatesTags: [{ type: 'Post', id: 'LIST' }] causes what?
Best Practices & Pitfalls
β Do
- Define one API slice per base URL and add all endpoints to it
- Always
.concat(apiSlice.middleware)inconfigureStore - Use
providesTags/invalidatesTagsso the UI stays in sync automatically - Use
isLoadingfor first load,isFetchingfor background refreshes - Let RTK Query own server state; keep Redux slices for genuine client state (UI, auth)
β Don't
- Fetch RTK Query data inside
useEffectβ the hook already does that - Copy query results into a separate slice β read them from the cache via the hook
- Manually track loading/error state β the hook exposes it
- Create a new
createApiper component β endpoints belong on the shared slice
π‘ Transforming responses
When the server's shape isn't what your UI wants, reshape it once at the endpoint with transformResponse instead of in every component:
getPosts: builder.query({
query: () => '/posts',
transformResponse: (response) =>
[...response].sort((a, b) => new Date(b.date) - new Date(a.date))
})
Summary
π Key Takeaways
- RTK Query generates the thunks, reducer, cache, and React hooks for server data
createApi+fetchBaseQuerydefine one API slice per base URL- Query endpoints read (and cache); mutation endpoints write
- Endpoints auto-generate
useXxxQuery/useXxxMutationhooks with rich status flags - Register the reducer at
reducerPathand concat the middleware β don't skip it providesTags+invalidatesTagskeep the UI in sync automatically
π Additional Resources
- RTK Query β Overview
- RTK Query β Queries
- RTK Query β Mutations
- RTK Query β Automated Re-fetching with Tags
π What's Next?
RTK Query caches server data flat by endpoint, but complex apps also need to manage relational client state efficiently. Next up: Normalized state structure β organizing collections as id-keyed lookups so updates stay fast and duplication-free.
π Data fetching, solved!
You've reached the modern peak of Redux: describe your endpoints, and the cache, hooks, and re-fetching take care of themselves.