If you're building a React app with Redux, you've probably heard about RTK Query. Maybe you're wondering if you should use it instead of TanStack Query or SWR. Or maybe you're confused about what it even does.
Let me break it down.
What Problem Does RTK Query Solve?
Like TanStack Query and SWR, RTK Query solves the data-fetching problem. Here's the traditional approach with useState and useEffect:
import { useState, useEffect } from "react";
function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch("/api/user")
.then((res) => res.json())
.then((data) => {
setUser(data);
setLoading(false);
})
.catch((err) => {
setError(err);
setLoading(false);
});
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>Hello, {user.name}!</div>;
}
This is tedious. You need caching, automatic refetching, loading states, error handling, and mutations. Writing this yourself for every API call is exhausting.
RTK Query handles all of this. But unlike TanStack Query and SWR, RTK Query is tightly integrated with Redux.
What Is RTK Query?
RTK Query is Redux's official data-fetching and caching solution. It's part of Redux Toolkit and was released in August 2021.
The key thing to understand: RTK Query isn't just a data-fetching library. It's a Redux-based data-fetching library.
Your fetched data lives in the Redux store. It works with Redux DevTools. You need Redux to use it, and it follows Redux patterns and conventions.
If you're already using Redux for state management, RTK Query is a natural fit. If you're not using Redux, you probably shouldn't start just for RTK Query.
How RTK Query Works
RTK Query uses an "API slice" pattern. Think of it like defining a single source of truth for all your API endpoints. You create one API definition that describes all your endpoints, and RTK Query auto-generates React hooks for each one. This keeps your API logic centralized and consistent across your app.
Here's the same user profile example with RTK Query:
// 1. Define your API slice (usually in a separate file)
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export const api = createApi({
reducerPath: "api",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
endpoints: (builder) => ({
getUser: builder.query({
query: () => "user",
}),
}),
});
// 2. Export auto-generated hooks
export const { useGetUserQuery } = api;
// 3. Add the API slice to your Redux store
import { configureStore } from "@reduxjs/toolkit";
export const store = configureStore({
reducer: {
[api.reducerPath]: api.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(api.middleware),
});
// 4. Use the hook in your component
function UserProfile() {
const { data: user, isLoading, error } = useGetUserQuery();
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>Hello, {user.name}!</div>;
}
More setup than TanStack Query or SWR, but you get structure. Once the API slice is defined, using it is clean.
RTK Query vs TanStack Query vs SWR
Let's compare the three major data-fetching libraries.
Bundle Size
- SWR: 5.3KB minified + gzipped
- TanStack Query: 16.2KB minified + gzipped
- RTK Query + Redux Toolkit: ~40KB minified (~14KB minified + gzipped)
Why is RTK Query larger?
RTK Query's bundle includes Redux core, Immer (for immutable updates), and other Redux Toolkit utilities. If you're already using Redux Toolkit in your app, the incremental cost of adding RTK Query is much smaller since these dependencies are shared.
Setup Complexity
TanStack Query - Minimal setup:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
);
}
SWR - Even simpler (optional config):
import { SWRConfig } from "swr";
function App() {
return (
<SWRConfig
value={
{
/* optional config */
}
}
>
<YourApp />
</SWRConfig>
);
}
RTK Query - Requires Redux store setup:
import { configureStore } from "@reduxjs/toolkit";
import { Provider } from "react-redux";
import { api } from "./api";
const store = configureStore({
reducer: {
[api.reducerPath]: api.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(api.middleware),
});
function App() {
return (
<Provider store={store}>
<YourApp />
</Provider>
);
}
RTK Query requires the most setup. But if you're already using Redux, you're halfway there.
API Design
TanStack Query uses inline definitions:
const { data } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetch(`/api/users/${userId}`).then((res) => res.json()),
});
SWR uses inline definitions:
const { data } = useSWR(`/api/users/${userId}`, (url) =>
fetch(url).then((res) => res.json()),
);
RTK Query uses predefined API slices:
// Define once
const api = createApi({
endpoints: (builder) => ({
getUser: builder.query({
query: (userId) => `users/${userId}`,
}),
}),
});
// Use anywhere
const { data } = useGetUserQuery(userId);
RTK Query is more verbose upfront but generates cleaner hooks. The tradeoff: less flexibility. Because RTK Query requires defining endpoints in the API slice, it adds some friction when co-locating data requirements with components.
Caching Strategy
TanStack Query & SWR use simple key-based caching. Cache by query key, easy to understand, works great for most apps.
RTK Query uses tag-based cache invalidation. It uses a powerful tag system to efficiently invalidate and refetch related queries. This gives you more control over cache invalidation patterns.
Example of RTK Query's tag system:
const api = createApi({
tagTypes: ["User"],
endpoints: (builder) => ({
getUsers: builder.query({
query: () => "users",
providesTags: ["User"],
}),
getUser: builder.query({
query: (id) => `users/${id}`,
providesTags: (result, error, id) => [{ type: "User", id }],
}),
updateUser: builder.mutation({
query: ({ id, ...patch }) => ({
url: `users/${id}`,
method: "PATCH",
body: patch,
}),
invalidatesTags: (result, error, { id }) => [{ type: "User", id }],
}),
}),
});
When you update a user, RTK Query automatically refetches all queries tagged with that user. Powerful for admin panels and dashboards.
TypeScript Support
All three have excellent TypeScript support. TanStack Query has great type inference. SWR has good type inference. RTK Query has excellent types plus code generation from OpenAPI specs.
RTK Query's killer feature: generate your entire API from OpenAPI/Swagger:
npx @rtk-query/codegen-openapi openapi-config.ts
This auto-generates TypeScript types and endpoints. Huge time-saver for teams with API specs.
DevTools
TanStack Query has dedicated DevTools for queries. SWR has community DevTools (unofficial). RTK Query uses Redux DevTools.
RTK Query piggybacks on Redux DevTools. You can inspect cache state, actions, subscriptions, and invalidations—though it's not as query-centric as TanStack Query's dedicated DevTools.
Community & Ecosystem
TanStack Query has the largest community and most resources. SWR has a good community, backed by Vercel. RTK Query has a smaller community, backed by the Redux team.
Market share estimate (2025, based on npm downloads + GitHub activity): TanStack Query dominates at 60-70%, SWR holds 15-20%, and RTK Query sits at 10-15%.
RTK Query's smaller share doesn't mean it's inferior. It's niche: Redux apps.
When to Choose RTK Query
Choose RTK Query if you're already using Redux for global state, need sophisticated cache invalidation for complex relational data, your team knows Redux patterns, you have OpenAPI/Swagger specs to generate code from, or you're building a large admin panel with complex data relationships.
Don't choose RTK Query if you're not using Redux (use TanStack Query or SWR instead), want the simplest solution, bundle size is critical, or you're building a simple app.
RTK Query's Killer Features
1. Tag-Based Cache Invalidation
Instead of manually invalidating cache keys, use tags:
const api = createApi({
tagTypes: ["Post", "User"],
endpoints: (builder) => ({
getPosts: builder.query({
query: () => "posts",
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({ type: "Post", id })),
{ type: "Post", id: "LIST" },
]
: [{ type: "Post", id: "LIST" }],
}),
createPost: builder.mutation({
query: (body) => ({
url: "posts",
method: "POST",
body,
}),
invalidatesTags: [{ type: "Post", id: "LIST" }],
}),
deletePost: builder.mutation({
query: (id) => ({
url: `posts/${id}`,
method: "DELETE",
}),
invalidatesTags: (result, error, id) => [{ type: "Post", id }],
}),
}),
});
When you create a post, the post list automatically refetches. When you delete a post, only that post's queries refetch. Smart.
3. Optimistic Updates with Redux
Optimistic updates are easier with Redux because you control the entire state:
const api = createApi({
endpoints: (builder) => ({
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({
url: `posts/${id}`,
method: "PATCH",
body: patch,
}),
async onQueryStarted({ id, ...patch }, { dispatch, queryFulfilled }) {
// Optimistically update cache
const patchResult = dispatch(
api.util.updateQueryData("getPost", id, (draft) => {
Object.assign(draft, patch);
}),
);
try {
await queryFulfilled;
} catch {
// Rollback on error
patchResult.undo();
}
},
}),
}),
});
The UI updates instantly. If the request fails, it rolls back. Slick.
4. Polling and Subscriptions
Built-in polling support:
function RealtimeUserList() {
const { data: users } = useGetUsersQuery(undefined, {
pollingInterval: 3000, // Refetch every 3 seconds
});
return (
<ul>
{users?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
This is built-in. No extra setup.
RTK Query in Practice: A Real Example
Let's build a simple blog post manager:
// api/postsApi.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export const postsApi = createApi({
reducerPath: "postsApi",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
tagTypes: ["Post"],
endpoints: (builder) => ({
getPosts: builder.query({
query: () => "posts",
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 }],
}),
createPost: builder.mutation({
query: (body) => ({
url: "posts",
method: "POST",
body,
}),
invalidatesTags: [{ type: "Post", id: "LIST" }],
}),
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({
url: `posts/${id}`,
method: "PATCH",
body: patch,
}),
invalidatesTags: (result, error, { id }) => [{ type: "Post", id }],
}),
deletePost: builder.mutation({
query: (id) => ({
url: `posts/${id}`,
method: "DELETE",
}),
invalidatesTags: (result, error, id) => [{ type: "Post", id }],
}),
}),
});
export const {
useGetPostsQuery,
useGetPostQuery,
useCreatePostMutation,
useUpdatePostMutation,
useDeletePostMutation,
} = postsApi;
Now use it:
// components/PostList.tsx
import { useGetPostsQuery, useDeletePostMutation } from "../api/postsApi";
function PostList() {
const { data: posts, isLoading } = useGetPostsQuery();
const [deletePost] = useDeletePostMutation();
if (isLoading) return <div>Loading...</div>;
return (
<ul>
{posts?.map((post) => (
<li key={post.id}>
{post.title}
<button onClick={() => deletePost(post.id)}>Delete</button>
</li>
))}
</ul>
);
}
// components/CreatePost.tsx
import { useState } from "react";
import { useCreatePostMutation } from "../api/postsApi";
function CreatePost() {
const [title, setTitle] = useState("");
const [createPost, { isLoading }] = useCreatePostMutation();
const handleSubmit = async (e) => {
e.preventDefault();
await createPost({ title, content: "" });
setTitle("");
};
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Post title"
/>
<button type="submit" disabled={isLoading}>
{isLoading ? "Creating..." : "Create Post"}
</button>
</form>
);
}
When you create a post, the list automatically updates. When you delete a post, it disappears. No manual cache management.
Should You Use RTK Query in 2025?
Use RTK Query if you're already using Redux, need sophisticated cache invalidation, have OpenAPI specs, or your team knows Redux.
Don't use it if you're starting a new project without Redux, want the simplest solution (use SWR), want the most popular option (use TanStack Query), or bundle size matters.
RTK Query isn't outdated or inferior. It's specialized. It's the best data-fetching solution for Redux apps. But if you're not using Redux, there's no reason to start.
Summary
RTK Query is Redux's official data-fetching library. It's built into Redux Toolkit (41KB total bundle including Redux, Immer, etc.), offers tag-based cache invalidation, OpenAPI code generation, and is best for Redux apps.
Compared to TanStack Query: TanStack Query is more popular, standalone, and smaller. RTK Query has Redux integration, tag-based invalidation, but more setup.
Compared to SWR: SWR is simplest with the smallest bundle. RTK Query has more features and structure.
For Redux users: RTK Query is the obvious choice. It integrates perfectly with your existing Redux setup. Don't switch to TanStack Query unless you're leaving Redux.
For non-Redux users: Choose TanStack Query or SWR. Don't adopt Redux just for RTK Query.
RTK Query is alive and well in 2025. It's not the most popular data-fetching library, but for Redux apps, it's often the best.

