The Right Way to Use RTK Query API Slice — Lessons Learned

When it comes to state management, RTK (Redux Toolkit) reigns supreme. And while Redux has streamlined the developer experience by reducing much of the boilerplate from legacy Redux, RTK remains complex—although it offers arguably the most robust, scalable solutions in return.
But before we dig in, there is a quick backstory to this one to give you context.
Browsing the RTK Query docs for an issue that would seem unrelated, I had a moment of realization: I've been using the query API all wrong for months. What I thought were clever architectural decisions (coming from Recoil) were actually been undermining the core advantages of RTK Query all along.
If you'd like to jump ahead, feel free to skip to the section that interests you most via the table of contents.
Issues like API slice maintenance and improper cache invalidation set off a domino effect, causing performance bottlenecks, countless hours of refactoring, and extended troubleshooting sessions - like the one that led me to the docs in the first place 🫤. But that's no more.
So, rather than let you stumble down the same path, I decided to write this piece to help you avoid the mistakes I made.
Objectives
This guide aims to help you transform your RTK Query setup for peak efficiency. Along the way, I'll highlight key practices that improve performance and maintainability, covering:
- An overview of RTK Query and the single API slice pattern.
- Best practices for maintaining code organization, and effective tag management, including
injectEndpoints
usage. - Unpopular but rampant pitfalls to avoid when using API slices.
Now let’s dig in.
A Brief Overview of RTK Query
Built on top of Redux Toolkit, RTK Query is a UI-agnostic data fetching tool that simplifies the process of fetching, caching, and synchronizing data in Redux-based applications. The tool provides a lightweight (at ~9kb) yet powerful set of utilities to define API endpoints and automatically manage server states, including loading
, error
, and success
states.

You could say it’s the Redux team's response to Tanstack's React Query, but with a broader scope that extends beyond just React apps. It is also designed as an improvement over the RTK + Redux Thunk
approach, reducing the need to write excessive boilerplate code for simple functions. So far, it has delivered across the board.
For a detailed comparison between RTK Query and similar tools, see the feature sets comparison table in the docs.
Prerequisites
This guide assumes that you're familiar with Redux, RTK (Redux Toolkit), and state management fundamentals. However, although my examples here are React-focused, React knowledge isn't mandatory; the principles I explain can be adapted across all Redux-based applications.
Understanding the RTK API Slice
When you call createApi
, RTK Query creates an API slice object, which comes with the following:
- A Redux reducer for cache management
- Middleware for handling cache lifetimes and subscriptions
- Auto-generated selectors and thunks for each endpoint
- React hooks (when imported from
@reduxjs/toolkit/query/react
)
Here's what a basic API slice looks like:
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export const api = createApi({
reducerPath: "api",
baseQuery: fetchBaseQuery({ baseUrl: "/api/" }),
tagTypes: ["Posts", "Users"],
endpoints: (builder) => ({
getPosts: builder.query({
query: () => "posts",
providesTags: ["Posts"],
}),
getUsers: builder.query({
query: () => "users",
providesTags: ["Users"],
}),
}),
});
The Single API Slice Pattern
The Single API Slice Pattern in RTK Query involves creating a single createApi
slice to manage all interactions with a particular base URL or service. This slice defines the endpoints, queries, mutations, and cache management related to the service.
Why One Slice Per Base URL?
It's a no-brainer that a single URL will keep things simple and manageable, especially when you have multiple endpoints tied to the same service. But there's more to why this pattern is so commonly used. Here are some key reasons:
- Tag Invalidation Works Within Slices enabling efficient data refreshes, where invalidating one tag can update all related endpoints using that tag.
// ✅ CORRECT: Single API slice for related endpoints
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: "/api/" }),
tagTypes: ["Posts", "Comments"],
endpoints: (builder) => ({
getPosts: builder.query({
query: () => "posts",
providesTags: ["Posts"],
}),
addComment: builder.mutation({
query: (comment) => ({
url: "comments",
method: "POST",
body: comment,
}),
invalidatesTags: ["Posts"], // This works!
}),
}),
});
// ❌ INCORRECT: Multiple API slices break tag invalidation ↓
const postsApi = createApi({
baseQuery: fetchBaseQuery({ baseUrl: "/api/" }),
tagTypes: ["Posts"],
endpoints: (builder) => ({
getPosts: builder.query({
query: () => "posts",
providesTags: ["Posts"],
}),
}),
});
const commentsApi = createApi({
baseQuery: fetchBaseQuery({ baseUrl: "/api/" }),
tagTypes: ["Comments"],
endpoints: (builder) => ({
addComment: builder.mutation({
query: (comment) => ({
url: "comments",
method: "POST",
body: comment,
}),
invalidatesTags: ["Posts"], // This won't work!
}),
}),
});
- Performance Considerations: Each API slice creates its own middleware, which checks every dispatched action. Thus, creating multiple unnecessary slices that can negatively impact performance:
// ✅ CORRECT: Single middleware for all endpoints
const store = configureStore({
reducer: {
[api.reducerPath]: api.reducer,
},
middleware: (getDefault) => getDefault().concat(api.middleware),
});
// ❌ INCORRECT: Multiple middlewares impact performance
const store = configureStore({
reducer: {
[postsApi.reducerPath]: postsApi.reducer,
[usersApi.reducerPath]: usersApi.reducer,
[commentsApi.reducerPath]: commentsApi.reducer,
},
middleware: (getDefault) =>
getDefault()
.concat(postsApi.middleware)
.concat(usersApi.middleware)
.concat(commentsApi.middleware), // Each middleware adds overhead
});
Advanced Option: Use the injectEndpoints
function
injectEndpoints
is an RTK Query function that allows you to add more endpoints to an existing API slice. Useful for when your application API needs start to evolve, and you need to split your endpoint definitions across multiple files, while maintaining a single API slice.
For example: Consider a large e-commerce application, you might start with an API slice for products, including endpoints for fetching product lists and details. As the app grows, you add features like user
getReviews
andupdateInventory
, amongst other.
Using injectEndpoints
, you can define new user reviews and inventory endpoints in separate files while maintaining a single API slice.
Here's how to use it:
// api/baseApi.ts
export const baseApi = createApi({
baseQuery: fetchBaseQuery({ baseUrl: "/api/" }),
tagTypes: ["Posts", "Users", "Comments"],
endpoints: () => ({}),
});
// api/postsApi.ts
export const postsApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getPosts: builder.query({
query: () => "posts",
providesTags: ["Posts"],
}),
}),
});
// api/usersApi.ts
export const usersApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getUsers: builder.query({
query: () => "users",
providesTags: ["Users"],
}),
}),
});
Best Practices for API Slice Organization
A well-organized API slice ensures that your code is maintainable and efficient, that way, you improve your team's productivty while keeping performance intact. Here are some best practices to follow when structuring your API slices:
Group by Base URL
- Keep related endpoints together, making your code easier to understand and maintain.
// ✅ CORRECT: Separate slices for different domains
const mainApi = createApi({
baseQuery: fetchBaseQuery({ baseUrl: "https://api.myapp.com" }),
endpoints: (builder) => ({
/* ... */
}),
});
const thirdPartyApi = createApi({
baseQuery: fetchBaseQuery({ baseUrl: "https://api.thirdparty.com" }),
endpoints: (builder) => ({
/* ... */
}),
});
Use Endpoint Builder Functions
Endpoint builders streamline the process of defining API endpoints, providing a concise syntax and built-in features like automatic data fetching and mutation handling.
// helpers/endpointBuilders.ts
export const buildPaginatedQuery = (builder, endpoint) =>
builder.query({
query: ({ page, limit }) => `${endpoint}?page=${page}&limit=${limit}`,
transformResponse: (response) => ({
data: response.items,
totalPages: response.total_pages,
}),
});
// api.ts
const api = createApi({
endpoints: (builder) => ({
getPosts: buildPaginatedQuery(builder, "posts"),
getUsers: buildPaginatedQuery(builder, "users"),
}),
});
Organize Tags Effectively
Tags help your API know when to refresh data and invalidate caches, while also providing a clear naming convention. A good tag organization structure will make codebase maintenance magnitudes easier.
const api = createApi({
tagTypes: ["PostList", "Post", "UserList", "User"],
endpoints: (builder) => ({
getPosts: builder.query({
query: () => "posts",
providesTags: ["PostList"],
}),
getPost: builder.query({
query: (id) => `posts/${id}`,
providesTags: (result, error, id) => [{ type: "Post", id }],
}),
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({
url: `posts/${id}`,
method: "PATCH",
body: patch,
}),
invalidatesTags: (result, error, { id }) => [
{ type: "Post", id },
"PostList",
],
}),
}),
});
By allocating a dedicated API slice to each base URL or service, you ensure:
- Proper cache invalidation across related endpoints
- Better performance through reduced middleware overhead
- Cleaner and more maintainable code organization
Common Pitfalls to Avoid with API Slices
Where there's a way, there's a wrong turn. Here are some common pitfalls to avoid when working with API slices:
- Creating Multiple Slices for Related Endpoints: This can lead to unnecessary complexity and redundancy, making it harder to manage and maintain your API.
- Duplicating Base URLs: Duplicating base URLs can increase the risk of inconsistencies and errors, especially when updating or modifying endpoints.
- Not Planning Tag Relationships: Neglecting to plan tag relationships can result in inefficient cache invalidation and data inconsistency.
- Ignoring Code Organization: Poor code organization will most likely make your API difficult to maintain, extend, and understand—even for you.
Conclusion
The single API slice pattern is not just a preference—it's a fundamental part of using RTK Query effectively, as evident in it's recommendation by the Redux team itself.
Remember, if you are ever tempted to take the anti-pattern route of creating multiple API slices for a single service/base URL, use injectEndpoints
instead. This approach helps you maintain a single, cohesive API slice, reduces redundancy, and enhances scalability by keeping your codebase clean and manageable.
The key is to approach your API architecture with a clear, big-picture mindset and carefully plan your tag relationships before writing the first API slice line of code. This way, you don't get to learn the way I did - the hard way! You're welcome.