RTK Query is Redux Toolkit's built-in data-fetching and caching layer. It takes care of the repetitive work around server state: caching, loading states, background refetching, and cache invalidation. The API looks straightforward at first glance, but the decisions we make early (where to define endpoints, how to structure cache tags, where to handle auth errors) either pay off or cause real friction as an app grows.
In this article, we will cover:
- Splitting API endpoints across feature files with
injectEndpoints - Writing a custom
baseQuerythat handles token refresh automatically - Dropping RTK Query into an existing Axios-based API layer
- Using cache tags precisely, only refetching what actually changed
- Transforming server responses at the endpoint level with
transformResponse - TypeScript integration for type-safe endpoints and cache
- Conditionally skipping queries with
skipandskipToken - Optimistic updates with
onQueryStarted
By the end, we will have a set of patterns that work together and can be adopted one at a time as an app grows.
The problem with one big API slice
Most RTK Query tutorials start by defining every endpoint inside a single createApi call. That works fine at small scale. The problem shows up later.
When every feature imports from the same file, unrelated parts of the app become coupled to each other in a way that is not obvious from the imports. The entire slice loads upfront, even endpoints a user may never trigger. And the file itself grows to hundreds of lines (users, posts, comments, settings, and notifications all sitting next to each other) until it becomes the one file in the codebase that nobody wants to touch.
Here is what that looks like in practice:
src/api/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Users', 'Posts', 'Comments', 'Settings'],
endpoints: (builder) => ({
getUsers: builder.query<User[], void>({
query: () => '/users',
}),
getUserById: builder.query<User, string>({
query: (id) => `/users/${id}`,
}),
createUser: builder.mutation<User, Partial<User>>({
query: (body) => ({ url: '/users', method: 'POST', body }),
}),
getPosts: builder.query<Post[], void>({
query: () => '/posts',
}),
getPostById: builder.query<Post, string>({
query: (id) => `/posts/${id}`,
}),
createPost: builder.mutation<Post, Partial<Post>>({
query: (body) => ({ url: '/posts', method: 'POST', body }),
}),
getComments: builder.query<Comment[], string>({
query: (postId) => `/posts/${postId}/comments`,
}),
getSettings: builder.query<Settings, void>({
query: () => '/settings',
}),
updateSettings: builder.mutation<Settings, Partial<Settings>>({
query: (body) => ({ url: '/settings', method: 'PATCH', body }),
}),
// ... 30 more endpoints
}),
})
This is the file we are trying to avoid. Every new feature adds more lines here. Every change to the users feature touches the same file as changes to posts and settings. The structure that starts simple becomes a maintenance problem the moment the app is more than a few features wide.
Code splitting with injectEndpoints
RTK Query's solution to this is injectEndpoints. Instead of putting every endpoint in one place, we define a lean base API with shared configuration, and each feature injects its own endpoints into that base from its own file. The features stay isolated; the shared setup lives in one place.
Let's start by creating the base API. Notice that the endpoints property returns an empty object. We are deliberately leaving it empty here:
src/api/base.api.slice.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const baseApi = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.token
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return headers
},
}),
tagTypes: ['Users', 'Posts', 'Comments', 'Settings'],
endpoints: () => ({}),
})
The tagTypes are declared centrally here because RTK Query needs to know them upfront for cache invalidation to work across all feature slices. The prepareHeaders function attaches the auth token to every outgoing request automatically.
Now let's see how each feature adds its own endpoints:
src/features/users/users.api.slice.ts
import { baseApi } from '@/api/base.api.slice'
const URLS = {
getUsers: '/users',
getUserById: (id: string) => `/users/${id}`,
}
const usersApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getUsers: builder.query<User[], void>({
query: () => ({ url: URLS.getUsers, method: 'GET' }),
}),
getUserById: builder.query<User, string>({
query: (id) => ({ url: URLS.getUserById(id), method: 'GET' }),
}),
createUser: builder.mutation<User, Omit<User, 'id'>>({
query: (data) => ({ url: URLS.getUsers, method: 'POST', body: data }),
}),
}),
})
export const {
useGetUsersQuery,
useGetUserByIdQuery,
useCreateUserMutation,
} = usersApi
src/features/posts/posts.api.slice.ts
import { baseApi } from '@/api/base.api.slice'
const URLS = {
getPosts: '/posts',
getPostById: (id: string) => `/posts/${id}`,
}
const postsApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getPosts: builder.query<Post[], void>({
query: () => ({ url: URLS.getPosts, method: 'GET' }),
}),
getPostById: builder.query<Post, string>({
query: (id) => ({ url: URLS.getPostById(id), method: 'GET' }),
}),
}),
})
export const { useGetPostsQuery, useGetPostByIdQuery } = postsApi
Let's digest what's happening here. injectEndpoints takes the same builder API as createApi and merges the new endpoints into baseApi at runtime. The generated hooks (useGetUsersQuery, useCreateUserMutation, and so on) are exported directly from the feature file. No component ever needs to import from the base API. When the users feature changes, we edit users.api.slice.ts. When posts change, we edit posts.api.slice.ts. The files stay focused and the coupling is gone.
The base query: handling auth and errors in one place
The prepareHeaders option we saw above attaches an auth token to every request, but it does not handle what happens when the server rejects the token. A 401 response means the token has expired. In most apps, the right response is to refresh the token silently and retry the original request.
The baseQuery is the single function RTK Query calls for every request, which makes it the right place to handle this, rather than repeating the logic in each endpoint individually. We wrap the standard fetchBaseQuery in a custom function that intercepts 401 responses and attempts a token refresh before giving up.
Here is that custom base query:
src/api/baseQueryWithReauth.ts
import {
fetchBaseQuery,
BaseQueryFn,
FetchArgs,
FetchBaseQueryError,
} from '@reduxjs/toolkit/query/react'
import { setToken, logout } from '@/features/auth/authSlice'
import type { RootState } from '@/store'
const baseQuery = fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.token
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return headers
},
})
export const baseQueryWithReauth: BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError
> = async (args, api, extraOptions) => {
let result = await baseQuery(args, api, extraOptions)
if (result.error?.status === 401) {
const refreshResult = await baseQuery(
{ url: '/auth/refresh', method: 'POST' },
api,
extraOptions,
)
if (refreshResult.data) {
api.dispatch(setToken(refreshResult.data as string))
result = await baseQuery(args, api, extraOptions)
} else {
api.dispatch(logout())
}
}
return result
}
Let's walk through what happens when a request fails with a 401. First, we run the original request and store the result. If that result has a 401 error, we immediately make a second request to /auth/refresh. If the refresh succeeds and we get new token data back, we dispatch setToken to update the Redux store, then retry the original request, this time with the new token in the headers. If the refresh itself fails, we dispatch logout() to clear the auth state. In every case, we return a result, so the calling hook always gets something to work with.
Then we use it in base.api.slice.ts instead of the inline fetchBaseQuery:
import { createApi } from '@reduxjs/toolkit/query/react'
import { baseQueryWithReauth } from './baseQueryWithReauth'
export const baseApi = createApi({
reducerPath: 'api',
baseQuery: baseQueryWithReauth,
tagTypes: ['Users', 'Posts', 'Comments', 'Settings'],
endpoints: () => ({}),
})
Every endpoint now gets token refresh behaviour for free, with no per-endpoint handling required.
Integrating RTK Query with an Existing Axios API Layer
If we have already built the API layer from React - The Road To Enterprise, we do not need to replace it. RTK Query and that API layer solve different problems and they work well together.
The book's src/api/api.ts configures an Axios instance and wraps it in typed get, post, patch, put, and delete methods. Feature API files import that wrapper and define endpoint functions with centralized URL constants. This gives us a single place to update base URLs, consistent request handling, and typed responses.
RTK Query adds what that layer does not provide: caching, request deduplication, background refetching, and automatic loading and error state per component. The integration point is a custom baseQuery that delegates to the book's api object instead of using fetchBaseQuery.
Let's see how that adapter looks:
src/api/axiosBaseQuery.ts
import { AxiosError } from 'axios'
import api from './api'
type AxiosBaseQueryArgs = {
url: string
method: 'get' | 'post' | 'put' | 'patch' | 'delete'
data?: unknown
params?: Record<string, unknown>
}
type AxiosBaseQueryError = {
status: number | undefined
data: unknown
}
export const axiosBaseQuery = async ({
url,
method,
data,
params,
}: AxiosBaseQueryArgs): Promise<
{ data: unknown } | { error: AxiosBaseQueryError }
> => {
try {
let result
if (method === 'get' || method === 'delete') {
result = await api[method](url, { params })
} else {
result = await api[method](url, data, { params })
}
return { data: result.data }
} catch (error) {
const axiosError = error as AxiosError
return {
error: {
status: axiosError.response?.status,
data: axiosError.response?.data,
},
}
}
}
Pass it to createApi in place of fetchBaseQuery:
src/api/base.api.slice.ts
import { createApi } from '@reduxjs/toolkit/query/react'
import { axiosBaseQuery } from './axiosBaseQuery'
export const baseApi = createApi({
reducerPath: 'api',
baseQuery: axiosBaseQuery,
tagTypes: ['Users', 'Posts'],
endpoints: () => ({}),
})
The feature API file keeps its URL constants and gains RTK Query's endpoint pattern:
src/api/users.api.slice.ts
import { baseApi } from './base.api.slice'
const URLS = {
getUsers: '/users',
createUser: '/users',
}
export type User = {
id: string
name: string
email: string
}
const usersApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getUsers: builder.query<User[], void>({
query: () => ({ url: URLS.getUsers, method: 'get' }),
}),
createUser: builder.mutation<User, Omit<User, 'id'>>({
query: (data) => ({
url: URLS.createUser,
method: 'post',
data,
}),
}),
}),
})
export const { useGetUsersQuery, useCreateUserMutation } = usersApi
The axiosBaseQuery adapter is the bridge between the two worlds. It accepts the same { url, method, data, params } shape that our feature endpoints produce, calls the appropriate method on the existing api object, and returns either { data } on success or { error } on failure, the shape RTK Query expects. Teams migrating incrementally can move one feature at a time. Routes that have not yet migrated keep calling the feature API functions directly. Routes that have migrated use the generated hooks. Both talk to the same Axios instance, so interceptors, auth headers, and base URL config apply everywhere without duplication.

React - The Road To Enterprise
Do you want to know how to create scalable and maintainable React apps with architecture that actually works?
Find out more

