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

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

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.

redis client list example

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:

  1. 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!
		}),
	}),
});
  1. 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 and updateInventory, 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

  1. 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.


. . .
Yusuf Abdulhafeez — software engineer and technical writer

ABOUT THE AUTHOR

Yusuf Abdulhafeez is a software engineer and technical writer, known for his work at Incogniton, Rumi, YoLoop, and beyond. He crafts engaging content across various technical domains, including data security, online privacy, and software development (Node.js, React, TypeScript, Python, etc.), reaching over a million readers with his insights.

When he's not building software, crafting content, or immersed in his MBA coursework, you'll find him outdoor running, working out in the gym, hanging out with friends, or cheering for Arsenal FC.