Writing async thunks, handling pending/fulfilled/rejected states, and managing cache invalidation by hand is tedious — it is the same pattern repeated for every API endpoint. RTK Query is a powerful data fetching and caching layer built directly into Redux Toolkit that eliminates this boilerplate. You define your API endpoints once in a createApi call, and RTK Query automatically generates React hooks (useGetPostsQuery, useCreatePostMutation), manages the loading/error/data state, caches results, and invalidates stale data when mutations run.
Defining an RTK Query API Service
// src/store/apiSlice.js
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export const blogApi = createApi({
reducerPath: "blogApi", // key in the Redux store
baseQuery: fetchBaseQuery({
baseUrl: "/api",
prepareHeaders: (headers, { getState }) => {
// Attach auth token from Redux store
const token = getState().auth.accessToken;
if (token) headers.set("Authorization", `Bearer ${token}`);
return headers;
},
}),
tagTypes: ["Post", "User"], // cache tag types for invalidation
endpoints: (builder) => ({
// ── Queries (GET) ──────────────────────────────────────────────────────
getPosts: builder.query({
query: ({ page = 1, tag = null, pageSize = 10 } = {}) => ({
url: "/posts",
params: { page, page_size: pageSize, ...(tag ? { tag } : {}) },
}),
providesTags: (result) =>
result
? [
...result.items.map(({ id }) => ({ type: "Post", id })),
{ type: "Post", id: "LIST" },
]
: [{ type: "Post", id: "LIST" }],
}),
getPostById: builder.query({
query: (postId) => `/posts/${postId}`,
providesTags: (result, error, postId) => [{ type: "Post", id: postId }],
}),
// ── Mutations (POST / PATCH / DELETE) ──────────────────────────────────
createPost: builder.mutation({
query: (postData) => ({
url: "/posts",
method: "POST",
body: postData,
}),
invalidatesTags: [{ type: "Post", id: "LIST" }],
// Invalidating "Post:LIST" refetches getPosts after a new post is created
}),
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: "PATCH",
body: patch,
}),
invalidatesTags: (result, error, { id }) => [{ type: "Post", id }],
}),
deletePost: builder.mutation({
query: (postId) => ({ url: `/posts/${postId}`, method: "DELETE" }),
invalidatesTags: (result, error, postId) => [
{ type: "Post", id: postId },
{ type: "Post", id: "LIST" },
],
}),
likePost: builder.mutation({
query: (postId) => ({ url: `/posts/${postId}/like`, method: "POST" }),
invalidatesTags: (result, error, postId) => [{ type: "Post", id: postId }],
}),
}),
});
// Export auto-generated hooks
export const {
useGetPostsQuery,
useGetPostByIdQuery,
useCreatePostMutation,
useUpdatePostMutation,
useDeletePostMutation,
useLikePostMutation,
} = blogApi;
createPost completes, it invalidates the { type: "Post", id: "LIST" } tag. Any component using useGetPostsQuery (which providesTags includes { type: "Post", id: "LIST" }) automatically refetches its data in the background. This solves cache coherence without writing any imperative refetch logic — you declare relationships between mutations and queries, and RTK Query handles the rest.configureStore: reducer: { [blogApi.reducerPath]: blogApi.reducer } and middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(blogApi.middleware). The middleware handles cache timing, request deduplication, and garbage collection of unused cached data. Forgetting the middleware is the most common setup mistake — without it, cache invalidation and automatic refetching do not work.useGetPostsQuery({ page: 1 }) and useGetPostsQuery({ page: 2 }) are separate cache entries. If you render the same query with slightly different argument objects on different renders (e.g., { page: currentPage } where a new object is created each time), RTK Query handles this correctly using deep equality for cache key comparison. However, passing non-serializable values (class instances, functions) as arguments breaks the cache key — always use plain objects and primitives.Adding RTK Query to the Store
// src/store/index.js (updated)
import { configureStore } from "@reduxjs/toolkit";
import authReducer from "./authSlice";
import uiReducer from "./uiSlice";
import { blogApi } from "./apiSlice";
export const store = configureStore({
reducer: {
auth: authReducer,
ui: uiReducer,
[blogApi.reducerPath]: blogApi.reducer, // ← RTK Query cache
},
middleware: (getDefault) =>
getDefault().concat(blogApi.middleware), // ← required for cache features
});
Using RTK Query Hooks in Components
import { useGetPostsQuery, useDeletePostMutation } from "@/store/apiSlice";
function PostFeed({ page, tag }) {
// Auto-fetches, caches, and refetches when page/tag changes
const { data, isLoading, isError, error } = useGetPostsQuery({ page, tag });
if (isLoading) return <PostListSkeleton />;
if (isError) return <ErrorMessage message={error?.data?.detail} />;
return (
<div>
<PostList posts={data?.items ?? []} />
<Pagination page={page} total={data?.total ?? 0} />
</div>
);
}
function DeleteButton({ postId }) {
const [deletePost, { isLoading }] = useDeletePostMutation();
async function handleDelete() {
if (!confirm("Delete this post?")) return;
await deletePost(postId);
// Automatically invalidates and refetches the posts list!
// No manual refetch needed.
}
return (
<button onClick={handleDelete} disabled={isLoading}>
{isLoading ? "Deleting..." : "Delete"}
</button>
);
}
Common Mistakes
Mistake 1 — Forgetting blogApi.middleware in configureStore
❌ Wrong — cache invalidation and refetch do not work:
configureStore({ reducer: { [blogApi.reducerPath]: blogApi.reducer } });
// No middleware! Mutations do not trigger refetches.
✅ Correct — always add the middleware.
Mistake 2 — Not using providesTags/invalidatesTags (stale cache)
❌ Wrong — creating a post does not refresh the list:
createPost: builder.mutation({ query: (data) => ({ url: "/posts", method: "POST", body: data }) });
// No invalidatesTags — getPosts cache never invalidated!
✅ Correct — add invalidatesTags: [{ type: "Post", id: "LIST" }].
Quick Reference
| Concept | RTK Query API |
|---|---|
| Create API | createApi({ reducerPath, baseQuery, tagTypes, endpoints }) |
| Query (GET) | builder.query({ query, providesTags }) |
| Mutation (POST/PATCH) | builder.mutation({ query, invalidatesTags }) |
| Use query | const { data, isLoading, isError } = useGetPostsQuery(args) |
| Use mutation | const [mutate, { isLoading }] = useCreatePostMutation() |
| Auth header | prepareHeaders: (headers, { getState }) => { ... } |