Sitemap

Understanding Redux Toolkit Query (RTK Query) in Modern React Applications

10 min readMar 19, 2026

--

Press enter or click to view image in full size

Redux has been one of the most widely used state management libraries in the React ecosystem. For years, developers used traditional Redux patterns involving actions, reducers, constants, middleware, and asynchronous logic through tools like Redux Thunk or Redux Saga. While powerful, that approach often led to a lot of boilerplate, especially when handling API requests.

Redux Toolkit Query, commonly called RTK Query, was introduced to solve that problem. It provides a modern, efficient, and scalable way to fetch, cache, update, and synchronize server data in Redux applications.

A good RTK Query setup typically uses apiSlice.injectEndpoints, builder.query, builder.mutation, and auto-generated hooks such as useGetProductsQuery and useCreateProductMutation. That means the application is using Redux Toolkit Query as its API and server-state management solution.

What is RTK Query?

RTK Query is a data fetching and caching tool built into Redux Toolkit. It is designed to handle API communication in a structured and predictable way while reducing the amount of code developers need to write.

Instead of manually creating:

  • action types
  • action creators
  • reducers
  • async thunks
  • loading state
  • error state
  • caching logic

RTK Query lets you define all of that in one central API slice.

In simple terms, RTK Query gives you:

  • a single place to define API endpoints
  • built-in request lifecycle handling
  • caching out of the box
  • automatic React hooks
  • refetching and invalidation support
  • less boilerplate than traditional Redux async logic

Why RTK Query was introduced

Before RTK Query, fetching data in Redux often looked like this:

  • Dispatch a “request started” action
  • Make the API call
  • Dispatch a “request success” action with data
  • Dispatch a “request failed” action on error
  • Store loading, success, and error states in reducers
  • Rebuild the same logic for every API endpoint

That got repetitive fast.

RTK Query was designed to make server-state management easier by handling the repetitive parts automatically.

This is important because server state is different from client state.

Client state vs server state

Client state is local application state such as:

  • modal visibility
  • selected tab
  • sidebar open/closed
  • theme mode
  • form draft values

Server state is data that comes from a backend, such as:

  • products
  • customer orders
  • inventory counts
  • discount coupons
  • shipping settings
  • user reviews

RTK Query is built mainly for server state.

The pattern this code uses

A typical RTK Query project follows the API slice architecture.

It often starts by importing a base API slice:

import { apiSlice } from "../../app/api/apiSlice";

That usually means a root API configuration already exists somewhere else in the app. Then a feature file extends it using:

apiSlice.injectEndpoints({
endpoints: (builder) => ({
...
}),
});

This is a common scaling strategy in large applications. Instead of putting every endpoint in one huge file, you define a base API slice and then inject feature-specific endpoints into it.

In an e-commerce admin dashboard, one file might handle endpoints related to:

  • products
  • categories
  • orders
  • customers
  • coupons
  • notifications
  • payments
  • inventory updates

So architecturally, this is a feature-based RTK Query endpoint module.

Core RTK Query concepts

To understand RTK Query well, it helps to know the main concepts it uses.

1. The API slice

The API slice is the central service definition for your backend communication.

A typical base API looks like this:

import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export const apiSlice = createApi({
reducerPath: "api",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
tagTypes: ["Orders", "Products", "Notifications"],
endpoints: () => ({}),
});

This base slice:

  • registers RTK Query in Redux
  • sets the base URL
  • defines reusable cache tags
  • provides a place for endpoint injection

Then feature files add endpoints with injectEndpoints.

2. Queries

A query is used for fetching data.

Example:

getProducts: builder.query({
query: () => ({
url: "/products",
method: "GET",
}),
keepUnusedDataFor: 60,
transformResponse: (response) => response,
}),

Queries are for read operations such as:

  • get products
  • get categories
  • get order history
  • get notifications
  • get payment summaries

In React, RTK Query automatically generates hooks for them:

const { data, error, isLoading } = useGetProductsQuery();

That means you do not need to manually dispatch an action or write reducers for loading and error states.

3. Mutations

A mutation is used for creating, updating, or deleting data.

Example:

createProduct: builder.mutation({
query: (data) => ({
url: "/products",
method: "POST",
body: data,
}),
}),

Mutations are for write operations such as:

  • create
  • update
  • delete
  • confirm payment
  • upload image
  • apply discount

React hooks are also auto-generated:

const [createProduct, { isLoading, error, data }] = useCreateProductMutation();

You call the mutation manually when needed:

await createProduct(productData);

4. Auto-generated hooks

One of the nicest parts of RTK Query is that it creates React hooks for every endpoint you define.

From this:

getOrders: builder.query({...})

RTK Query generates:

useGetOrdersQuery

From this:

updateOrderStatus: builder.mutation({...})

It generates:

useUpdateOrderStatusMutation

That removes a lot of glue code.

5. Response transformation

Some endpoints use transformResponse.

Example:

getAllOrders: builder.query({
query: () => "/orders",
keepUnusedDataFor: 60,
transformResponse: (response) => response.data,
}),

This is useful when your backend wraps actual payloads inside a larger object.

For example, if the server returns:

{
"success": true,
"message": "Orders fetched successfully",
"data": [...]
}

You can extract only data so components receive a cleaner structure.

Without transformation, components would need to do:

data?.data

With transformation, they can do:

data

That keeps UI components cleaner.

6. Cache management

RTK Query caches fetched data automatically.

If a component calls:

useGetProductsQuery();

RTK Query stores the result in Redux. If another component requests the same data, RTK Query can reuse the cached result instead of making another network request.

Example configuration:

keepUnusedDataFor: 60

This means cached data stays around for 60 seconds after the last component stops using it.

This improves performance and reduces unnecessary requests.

7. Tag-based invalidation

A notifications example might use tags like this:

addAdminNotification: builder.mutation({
query: (data) => ({
url: "/notifications",
method: "POST",
body: data,
}),
invalidatesTags: ["Notifications"],
}),

And the corresponding query provides that tag:

getAdminNotifications: builder.query({
query: () => ({
url: "/notifications",
method: "GET",
}),
providesTags: ["Notifications"],
keepUnusedDataFor: 60,
transformResponse: (response) => response,
}),

This is one of RTK Query’s most useful features.

Here is what happens:

  • getAdminNotifications fetches notifications and marks the cache with the Notifications tag
  • addAdminNotification invalidates that tag after a successful mutation
  • RTK Query knows the notifications query is now stale
  • it automatically refetches the notifications

That means you do not have to manually dispatch a second fetch action after a mutation.

Why this approach is better than traditional Redux async patterns

Traditional Redux async data flow often required several files:

  • constants
  • actions
  • reducer
  • thunk

For just one endpoint.

With RTK Query, most of that lives in one endpoint definition.

Traditional style

You might have needed:

  • FETCH_PRODUCTS_REQUEST
  • FETCH_PRODUCTS_SUCCESS
  • FETCH_PRODUCTS_FAIL
  • action creators
  • reducer cases
  • thunk function
  • selector functions

RTK Query style

You define:

getProducts: builder.query({
query: () => ({
url: "/products",
method: "GET",
}),
})

And then consume:

const { data, isLoading, error } = useGetProductsQuery();

That is a massive simplification.

Breaking down the file structurally

An RTK Query file can usually be understood in layers.

1. Feature grouping

Although a file may be large, the endpoints are often grouped by business domain:

  • product management
  • inventory
  • categories
  • coupons
  • customers
  • orders
  • notifications
  • payments

That is good from a domain perspective, though the file may become big enough that splitting it improves maintainability.

2. Endpoint style consistency

Most endpoints follow a predictable structure:

someMutation: builder.mutation({
query: (data) => ({
url: endpoint,
method: "POST",
body: data,
}),
}),

or

someQuery: builder.query({
query: () => ({
url: endpoint,
method: "GET",
}),
}),

That consistency is helpful for teams.

3. Use of extraOptions

Some endpoints may include:

extraOptions: { serviceKey: "billing" },

This suggests the app likely uses a custom baseQuery wrapper that routes some requests to a different backend service, probably billing or payment-related.

Get Ralphbetta’s stories in your inbox

Join Medium for free to get updates from this writer.

That means the architecture is not just RTK Query, but a customized RTK Query multi-service API layer.

This is a practical enterprise-style pattern.

Likely architecture behind extraOptions: { serviceKey: "billing" }

Although that code is not shown, the base query may look something like this conceptually:

const dynamicBaseQuery = async (args, api, extraOptions) => {
const serviceKey = extraOptions?.serviceKey;
  let baseUrl = MAIN_API_URL;  if (serviceKey === "billing") {
baseUrl = BILLING_SERVICE_URL;
}
return fetchBaseQuery({ baseUrl })(args, api, extraOptions);
};

This allows one RTK Query setup to speak to multiple backends.

That is a neat pattern when your app depends on:

  • main application API
  • billing service
  • auth service
  • analytics service

How components use this setup

A query hook is used like this:

const { data, isLoading, error } = useGetOrdersQuery();

A mutation hook is used like this:

const [createCoupon, { isLoading, error }] = useCreateCouponMutation();
const handleCreate = async () => {
try {
const result = await createCoupon(payload).unwrap();
console.log(result);
} catch (err) {
console.error(err);
}
};

The .unwrap() call is especially useful because it converts the mutation result into a standard promise:

  • success resolves with raw response data
  • failure throws an error you can catch with try/catch

That makes async form submission logic cleaner.

Important strengths of RTK Query

1. Less boilerplate

This is probably the biggest win.

2. Built-in caching

No need to manually store and reuse fetched data.

3. Auto-generated hooks

Cleaner React code.

4. Automatic loading and error states

No manual reducer flags needed for every request.

5. Refetching and invalidation

A huge help after create, update, or delete operations.

6. Scales well

Especially when using a base apiSlice and injectEndpoints.

7. Good developer experience

It is easier to read and maintain than older Redux async code.

Limitations and trade-offs

RTK Query is great, but not magic.

1. Large files can get messy

A large endpoint file can work fine, but once it grows too much, splitting it helps. You might separate things into:

  • productApiSlice.js
  • orderApiSlice.js
  • couponApiSlice.js
  • notificationApiSlice.js

All can still inject into the same apiSlice.

2. Naming consistency matters a lot

There can be naming issues that confuse future developers.

For example:

  • getAdminNotifcations should probably be getAdminNotifications
  • comments saying DEPRECIATED should be deprecated

Small issues, but they add friction over time.

3. Tagging is often underused

Sometimes only one area, such as notifications, uses invalidation properly. Many other mutation-query relationships could benefit from it.

For example:

  • after creating a product, invalidate the product list
  • after deleting a coupon, invalidate coupons
  • after updating inventory, invalidate product stock data
  • after changing an order status, invalidate orders

Without invalidation, components may show stale data until manually refetched.

4. Response shapes can become inconsistent

Some endpoints may return response, while others return response.data.

That can create inconsistency in the UI layer. A more uniform transform strategy makes the app easier to work with.

Best practices for improving this codebase

1. Split the API file by domain

Even if all files inject into the same base API, separating concerns helps a lot.

For example:

  • productApi.js
  • orderApi.js
  • inventoryApi.js
  • paymentApi.js
  • notificationApi.js

2. Add tags for more entities

Example:

tagTypes: ["Products", "Orders", "Coupons", "Notifications"]

Then:

getProducts: builder.query({
query: () => ({
url: "/products",
method: "GET",
}),
providesTags: ["Products"],
}),

And:

createProduct: builder.mutation({
query: (data) => ({
url: "/products",
method: "POST",
body: data,
}),
invalidatesTags: ["Products"],
}),

That keeps data fresh automatically.

3. Standardize response transformation

Choose one pattern:

  • either always return raw backend response
  • or always transform to the useful payload

Most teams prefer transforming at the API layer so components stay simple.

4. Use clearer endpoint names

For example:

  • getAdminNotifcations → getAdminNotifications
  • createStoreDiscount might be clearer than addDiscount
  • getProductBySlug might be clearer than getSingleProduct

Cleaner names make the file easier to scan.

5. Consider TypeScript if the project allows it

RTK Query becomes even stronger with TypeScript because endpoint args and responses are typed.

Example:

getProducts: builder.query<Product[], void>({
query: () => ({
url: "/products",
method: "GET",
}),
}),

That improves autocomplete and catches mistakes earlier.

RTK Query vs Redux Thunk

A lot of people ask whether RTK Query replaces Redux Thunk entirely.

Not always.

RTK Query is best for:

  • API requests
  • caching server responses
  • syncing remote data

Redux Thunk is still useful for:

  • complex client-side orchestration
  • multi-step workflows not centered around API caching
  • local side effects and custom business logic

That said, for most CRUD-style backend communication, RTK Query is usually the better fit.

RTK Query vs React Query

Another common comparison is RTK Query vs React Query.

Both solve server-state problems well.

RTK Query

Best when:

  • your app already uses Redux
  • you want API state inside Redux
  • you like centralized endpoint definitions
  • you want Redux DevTools visibility

React Query

Best when:

  • your app does not use Redux
  • you want a library focused purely on server state
  • you prefer a hooks-first data fetching model without Redux coupling

If your application already uses Redux, RTK Query is a very logical choice.

What this architecture says about the app

A well-structured RTK Query file tells us a lot about the application design.

It suggests:

  • the app uses Redux Toolkit
  • server communication is centralized
  • the backend has many domain-specific endpoints
  • payment logic may use a separate service
  • React components consume data through generated hooks
  • some areas already use tag-based refetching
  • the codebase is reasonably mature and feature-rich

This is a modern Redux architecture, even if there is room to improve naming, tagging, and file organization.

A simplified mental model of RTK Query

You can think of RTK Query like this:

  • createApi creates the API engine
  • injectEndpoints adds features to that engine
  • builder.query defines reads
  • builder.mutation defines writes
  • hooks are auto-generated for React
  • cache is handled automatically
  • tags keep stale data in sync

That is really the core of it.

Example: converting old Redux logic to RTK Query

Old style:

dispatch(fetchProductsStart());
try {
const response = await axios.get("/products");
dispatch(fetchProductsSuccess(response.data));
} catch (error) {
dispatch(fetchProductsFail(error));
}

RTK Query style:

getProducts: builder.query({
query: () => "/products",
});

Then in a component:

const { data, isLoading, error } = useGetProductsQuery();

Much cleaner. Much less repetition.

Final conclusion

The approach shown here is Redux Toolkit Query (RTK Query), which is the modern Redux approach for managing API calls and server-side state.

It replaces a lot of traditional Redux boilerplate by giving you:

  • centralized endpoint definitions
  • built-in caching
  • automatic loading and error handling
  • generated React hooks
  • cache invalidation support
  • cleaner and more scalable async data flow

This implementation specifically uses:

  • a base API slice
  • injected endpoints
  • queries for reads
  • mutations for writes
  • custom extra options for multi-service routing
  • tag-based invalidation where needed

So this is best described as a feature-based RTK Query API slice architecture in Redux Toolkit.

It is a strong and modern approach, especially for applications with lots of backend interaction.

--

--