RTK Query: Server State Management in React
Imagine you’re building an e-commerce application.
Your product listing page fetches products from an API. When the user opens a product details page and then returns to the listing page, should the application fetch the same products again?
What happens when the user adds a new product, updates an existing record, or deletes an item? How do you keep the UI synchronized with the server?
You could manage all of this manually using useEffect, useState, and fetch(). But as your application grows, you'll need to handle loading states, errors, caching, duplicate requests, refetching, and keeping data consistent across components.
This is where RTK Query comes in.
RTK Query is a powerful data-fetching and caching tool included in Redux Toolkit. It simplifies communication with APIs and helps you manage server data without writing repetitive fetching logic.
In this article, we’ll understand RTK Query from scratch, build a small React application, learn how queries and mutations work, and explore caching, tags, and cache invalidation.
If you’ve already read my previous article on Redux Toolkit, you’ll find that RTK Query builds on many of the same concepts.
1. First, Understand the Problem
Let’s start with a common React pattern.
Suppose you want to fetch a list of products from an API.
Without RTK Query, you might write something like this:
import { useEffect, useState } from "react";
function Products() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchProducts() {
try {
const response = await fetch(
"https://dummyjson.com/products"
);
if (!response.ok) {
throw new Error("Failed to fetch products");
}
const data = await response.json();
setProducts(data.products);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchProducts();
}, []);
if (loading) return <p>Loading products...</p>;
if (error) return <p>{error}</p>;
return (
<div>
{products.map((product) => (
<p key={product.id}>{product.title}</p>
))}
</div>
);
}
export default Products;This works perfectly well for a simple example.
But imagine using the same approach across dozens of components.
Each component might need to manage:
- Loading states.
- Error handling.
- API requests.
- Response data.
- Caching.
- Refetching.
- Duplicate requests.
- Synchronization after creating, updating, or deleting data.
You can build reusable hooks and utilities to solve these problems yourself, but you’ll need to maintain that infrastructure.
The real challenge isn’t making an API request. It’s managing server data consistently across an application.
RTK Query helps solve this problem.
2. What Is RTK Query?
RTK Query is a data-fetching and caching solution that comes with Redux Toolkit.
It provides utilities to define API endpoints, execute requests, cache responses, manage request status, and refresh data when necessary.
Instead of writing repetitive API logic inside every component, you define your API endpoints in one place and use generated React hooks to interact with them.
Conceptually, the architecture looks like this:
React Components
|
v
RTK Query
|
+---------+---------+
| |
Queries Mutations
| |
v v
Fetch Data Change Data
| |
+---------+---------+
|
v
API Server
|
v
Cached API Data
|
v
Updated React UIFor example, you might define these operations:
getProducts → Fetch all products
getProductById → Fetch one product
addProduct → Create a product
updateProduct → Update a product
deleteProduct → Delete a productRTK Query helps manage the lifecycle of these operations.
What does RTK Query provide?
- Automatically generated React hooks.
- Loading, success, and error states.
- Caching of query results.
- Deduplication of equivalent in-flight queries.
- Refetching when configured conditions are met.
- Cache invalidation after mutations.
- Request cancellation and lifecycle management.
- Integration with the Redux store.
The key idea is simple:
You describe what data your application needs and how to fetch or modify it. RTK Query handles much of the repetitive API state management for you.
3. Query vs Mutation
Before writing code, you need to understand two important concepts: queries and mutations.
3.1 What is a Query?
A query is used to retrieve data from an API.
For example:
GET /products
GET /products/1
GET /users
GET /ordersIn RTK Query, we define a query using builder.query().
For example:
getProducts: builder.query({
query: () => "/products"
})RTK Query uses this endpoint definition to make the request and manage its result.
When you use the generated hook, it provides information such as:
data— The latest available result.isLoading— The initial request is loading and no data is available yet.isFetching— A request is currently in progress, including background refetches.error— Information about a failed request, when present.
Queries are generally used for reading data.
3.2 What is a Mutation?
A mutation is used for operations that create, update, or delete data.
For example:
POST /products/add
PUT /products/1
PATCH /products/1
DELETE /products/1In RTK Query, we define a mutation using builder.mutation().
For example:
addProduct: builder.mutation({
query: (newProduct) => ({
url: "/products/add",
method: "POST",
body: newProduct
})
})A mutation generates a hook that you can call when a user performs an action.
For example, clicking an Add Product button can trigger the mutation.
Query vs Mutation
+----------------------+---------------------------+
| Query | Mutation |
+----------------------+---------------------------+
| Reads data | Changes data |
| builder.query() | builder.mutation() |
| Often runs on mount | Usually triggered manually|
| Results are cached | Results aren't query cache|
| Can refetch | Can invalidate query cache|
+----------------------+---------------------------+This is a simplified comparison. Queries can also be triggered manually using lazy query hooks, and mutation results can be retained in their own mutation state. Mutations commonly trigger refetching by invalidating related query cache entries.
4. Build Your First RTK Query Application
Let’s build a small product listing application using React and RTK Query.
We’ll use the DummyJSON API:
https://dummyjson.com/products
Our application will:
- Fetch products.
- Display a loading message.
- Handle errors.
- Display the product list.
- Add a product using a mutation.
- Demonstrate cache invalidation.
We’ll use Vite for the React project.
Step 1: Create the React project
Run the following commands:
npm create vite@latest rtk-query-demo -- --template react
cd rtk-query-demo
npm install
npm install @reduxjs/toolkit react-redux
npm run devWe install two important packages:
@reduxjs/toolkit — Provides RTK Query and Redux Toolkit utilities.
react-redux — Connects React components to the Redux store.
Now let’s build the application.
Step 2: Create the API slice
Create a file named src/services/productsApi.js.
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export const productsApi = createApi({
reducerPath: "productsApi",
baseQuery: fetchBaseQuery({
baseUrl: "https://dummyjson.com"
}),
tagTypes: ["Product"],
endpoints: (builder) => ({
getProducts: builder.query({
query: () => "/products",
providesTags: (result) =>
result
? [
...result.products.map(({ id }) => ({
type: "Product",
id
})),
{ type: "Product", id: "LIST" }
]
: [{ type: "Product", id: "LIST" }]
}),
addProduct: builder.mutation({
query: (newProduct) => ({
url: "/products/add",
method: "POST",
body: newProduct
}),
invalidatesTags: [
{ type: "Product", id: "LIST" }
]
})
})
});
export const {
useGetProductsQuery,
useAddProductMutation
} = productsApi;This is the heart of our RTK Query implementation.
Let’s understand each part.
What is createApi()?
createApi({
reducerPath: "productsApi",
baseQuery: fetchBaseQuery({
baseUrl: "https://dummyjson.com"
}),
endpoints: (builder) => ({})
});createApi() creates an API service that defines how requests are made, how query results are cached, and how endpoints behave.
It also generates hooks for the endpoints you define.
What is reducerPath?
reducerPath: "productsApi"This specifies the key under which RTK Query’s reducer will be registered in the Redux store.
It also identifies the API slice’s state namespace.
What is fetchBaseQuery()?
baseQuery: fetchBaseQuery({
baseUrl: "https://dummyjson.com"
})fetchBaseQuery() is a lightweight wrapper around the browser's Fetch API.
It simplifies common request handling, including constructing URLs and processing responses.
Because we configured a base URL, we can write:
query: () => "/products"instead of repeating the entire URL in every endpoint.
What are endpoints?
The endpoints property defines the API operations available to our application.
In this example, we define two endpoints:
getProducts— Fetches the product list.addProduct— Sends a request to create a product.
RTK Query generates React hooks from these definitions.
For example:
useGetProductsQuery
useAddProductMutationThese hooks let React components interact with the API.
5. Configure the Redux Store
Creating the API slice isn’t enough. We also need to register its reducer and middleware in the Redux store.
Create src/app/store.js.
import { configureStore } from "@reduxjs/toolkit";
import { productsApi } from "../services/productsApi";
export const store = configureStore({
reducer: {
[productsApi.reducerPath]: productsApi.reducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(
productsApi.middleware
)
});Let’s understand the two important pieces.
Registering the API reducer
[productsApi.reducerPath]: productsApi.reducerThis registers the API slice’s reducer under its configured state key.
RTK Query uses this state to maintain query results, request status, and cache information.
Registering the API middleware
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(
productsApi.middleware
)The middleware handles important RTK Query behavior, including query subscriptions, cache lifetimes, request lifecycle management, and invalidation.
Both the reducer and middleware are required for the standard RTK Query setup.
6. Provide the Store to React
Open src/main.jsx and update it:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import App from "./App";
import { store } from "./app/store";
createRoot(document.getElementById("root")).render(
<StrictMode>
<Provider store={store}>
<App />
</Provider>
</StrictMode>
);The Redux Provider makes the store available to React components.
Since RTK Query is integrated into that store, its generated hooks can now be used in our components.
7. Fetch Products Using useGetProductsQuery()
Now create or update src/App.jsx.
import {
useGetProductsQuery,
useAddProductMutation
} from "./services/productsApi";
function App() {
const { data, error, isLoading, isFetching } = useGetProductsQuery();
const [ addProduct, { isLoading: isAdding } ] = useAddProductMutation();
const handleAddProduct = async () => {
try {
await addProduct({
title: "New Keyboard",
price: 2500
}).unwrap();
alert("Product added successfully!");
} catch (error) {
alert("Failed to add product");
}
};
if (isLoading) {
return <p>Loading products...</p>;
}
if (error) {
return <p>Failed to load products.</p>;
}
return (
<div>
<h1>RTK Query Demo</h1>
{isFetching && <p>Refreshing products...</p>}
<button
onClick={handleAddProduct}
disabled={isAdding}
>
{isAdding ? "Adding..." : "Add Product"}
</button>
<h2>Products</h2>
{data.products.map((product) => (
<div key={product.id}>
<h3>{product.title}</h3>
<p>Price: ${product.price}</p>
</div>
))}
</div>
);
}
export default App;Let’s break this down.
Reading data
const {
data,
error,
isLoading,
isFetching
} = useGetProductsQuery();Calling useGetProductsQuery() subscribes the component to the query identified by the endpoint and its arguments.
Because our endpoint doesn’t require an argument, we call the hook without one.
Get Rohit Kuwar’s stories in your inbox
Join Medium for free to get updates from this writer.
RTK Query starts the request when needed and provides the current query state.
Understanding isLoading and isFetching
These properties are related, but they aren’t identical.
isLoading
Indicates the initial loading state when the query has no data available yet.
isFetching
Indicates that a request is in progress. This can include background refetches when previous data is already available.
For example, you might use isLoading to display a full-page loading indicator and isFetching to display a smaller refreshing message.
Adding a product
await addProduct({
title: "New Keyboard",
price: 2500
}).unwrap();The generated mutation hook provides a trigger function.
Calling addProduct() starts the mutation request.
The .unwrap() method lets us handle the fulfilled result or catch a rejected request using normal try...catch syntax.
One important note: DummyJSON simulates product creation. It returns a sample response but does not permanently save the new product on its server. This example demonstrates the request and mutation workflow, not persistent product creation.
8. How Does RTK Query Caching Work?
Caching is one of the most useful RTK Query features.
Imagine a user opens the product listing page and RTK Query fetches the products.
The results are stored in the Redux store’s API cache.
Later, the user navigates to another page and then returns to the product listing page.
If the same query is still cached and no refetch condition requires a new request, RTK Query can reuse the cached data instead of immediately fetching it again.
Conceptually:
User opens Products Page
|
v
RTK Query requests products
|
v
Server returns product list
|
v
RTK Query caches the result
|
v
User navigates away
|
v
User returns to Products Page
|
v
Cached data may be reusedThe exact behavior depends on the query arguments, subscription state, cache lifetime, and refetch configuration.
What identifies a cached query?
RTK Query identifies query cache entries using the endpoint name and its serialized arguments.
For example:
useGetProductByIdQuery(1)and:
useGetProductByIdQuery(2)represent different queries because their arguments differ.
But two components using:
useGetProductByIdQuery(1)can share the same cache entry.
This helps avoid unnecessary duplicate requests and lets multiple components use the same cached result.
How long does cached data stay?
By default, RTK Query keeps unused query data cached for 60 seconds after the last subscriber unsubscribes.
This behavior is controlled by keepUnusedDataFor, which can be configured globally or per endpoint.
For example:
getProducts: builder.query({
query: () => "/products",
keepUnusedDataFor: 120
})This keeps unused cached data for up to 120 seconds after the last subscription ends.
Remember that cache lifetime and data freshness are different concepts. Keeping data in the cache doesn’t automatically mean it will be refetched every time the component mounts.
9. What Are providesTags and invalidatesTags?
Now let’s explore one of RTK Query’s most important features: cache invalidation.
Imagine the product listing page has already fetched and cached the products.
The user then adds a product, updates an existing product, or deletes a product.
The cached list might no longer represent the current server data.
How do we tell RTK Query which cached queries should be refreshed?
This is where tags help.
What is providesTags?
A query can use providesTags to identify the data represented by its cached result.
For example:
getProducts: builder.query({
query: () => "/products",
providesTags: [
{ type: "Product", id: "LIST" }
]
})This indicates that the query provides the Product list tag.
Tags are metadata used by RTK Query to track relationships between queries and mutations.
What is invalidatesTags?
A mutation can use invalidatesTags to indicate that some cached data may now be stale.
For example:
addProduct: builder.mutation({
query: (newProduct) => ({
url: "/products/add",
method: "POST",
body: newProduct
}),
invalidatesTags: [
{ type: "Product", id: "LIST" }
]
})When the mutation succeeds, RTK Query invalidates the specified tag.
If an active query provides that tag, RTK Query will normally refetch it so the component can receive updated data.
The flow looks like this:
Product List Query
|
v
Provides Product LIST Tag
|
v
User Adds a Product
|
v
Mutation Succeeds
|
v
Product LIST Tag Invalidated
|
v
Active Product Query Refetches
|
v
UI Receives Updated DataThis is the relationship between providesTags and invalidatesTags.
providesTags connects cached queries to data categories. invalidatesTags tells RTK Query which cached data may need refreshing after a mutation.
A note about our DummyJSON example
Because DummyJSON doesn’t persist the newly created product, refetching its product list won’t necessarily show the simulated new product.
In a real backend that saves the product, the refetched query can return the updated list.
This distinction is important: cache invalidation triggers data synchronization, but it cannot make a backend persist data that it never saved.
10. How Does RTK Query Differ from createAsyncThunk()?
If you’ve read my Redux Toolkit article, you might remember createAsyncThunk().
Both createAsyncThunk() and RTK Query support asynchronous operations, but they solve different problems.
createAsyncThunk()
createAsyncThunk() helps you manage asynchronous workflows.
For example, you can fetch products, dispatch pending/fulfilled/rejected actions, and store the result in a slice.
However, you generally need to implement the surrounding state management yourself, including caching and synchronization when required.
RTK Query
RTK Query is designed specifically for data fetching and caching.
It provides endpoint definitions, generated hooks, query caching, subscription management, and cache invalidation.
Here’s a quick comparison:
+----------------------+-------------------------+
| Feature | createAsyncThunk |
+----------------------+-------------------------+
| Main purpose | Async workflows |
| Generated API hooks | No |
| Query caching | Manual implementation |
| Cache invalidation | Manual implementation |
| Request status | Lifecycle actions |
| State integration | Usually slice reducers |
+----------------------+-------------------------+
+----------------------+-------------------------+
| Feature | RTK Query |
+----------------------+-------------------------+
| Main purpose | API data management |
| Generated API hooks | Yes, with React entry |
| Query caching | Built in |
| Cache invalidation | Tag-based |
| Request status | Managed by API slice |
| State integration | Built into Redux store |
+----------------------+-------------------------+Use createAsyncThunk() when you need a custom asynchronous workflow that doesn't fit naturally into query caching.
Use RTK Query when you’re managing API data that benefits from fetching, caching, refetching, and synchronization.
You can also use both in the same application when their responsibilities are clearly separated.
11. Useful RTK Query Features to Know
Once you’re comfortable with basic queries and mutations, these features are worth exploring.
Refetch on mount or argument change
You can configure a query to refetch when a component subscribes or its arguments change:
useGetProductsQuery(undefined, {
refetchOnMountOrArgChange: true
});This tells RTK Query to refetch when the hook mounts or its argument changes, subject to its query subscription behavior.
You can also use a numeric value to refetch when cached data is older than a specified number of seconds.
Refetch on focus
useGetProductsQuery(undefined, {
refetchOnFocus: true
});This enables refetching when the browser window regains focus.
To use focus and reconnect refetching, enable listener support once in your application entry point:
import { setupListeners } from "@reduxjs/toolkit/query";
setupListeners(store.dispatch);Refetch on reconnect
useGetProductsQuery(undefined, {
refetchOnReconnect: true
});This enables refetching when the browser regains network connectivity, with listener support configured.
Polling
For data that needs periodic updates, RTK Query supports polling:
useGetProductsQuery(undefined, {
pollingInterval: 10000
});This requests fresh data every 10 seconds while the subscription is active, subject to the configured polling behavior.
Polling can be useful for dashboards and status pages, but choose an interval appropriate for the server and application.
For true real-time communication, technologies such as WebSockets or Server-Sent Events may be more suitable.
12. Common Mistakes Beginners Should Avoid
Mistake 1: Forgetting to register the API middleware
Registering only the API reducer isn’t enough.
For the standard RTK Query setup, you need both:
[productsApi.reducerPath]: productsApi.reducerand:
productsApi.middlewareMistake 2: Confusing queries and mutations
Queries are primarily for retrieving data, while mutations represent operations that change server data.
Choosing the appropriate endpoint type makes the API behavior easier to understand.
Mistake 3: Expecting cache invalidation to modify the server
Invalidating a tag doesn’t create or update server data.
It marks related cached data as potentially stale and can trigger refetching.
The backend remains responsible for storing and returning the actual data.
Mistake 4: Treating caching as permanent storage
RTK Query’s cache is designed to manage API data during the application’s runtime.
It doesn’t automatically provide permanent storage across browser restarts.
Mistake 5: Refetching everything after every mutation
You don’t always need to invalidate every tag.
Use specific tags when possible so that only the relevant cached queries are refreshed.
For example, updating one product can invalidate that product’s individual tag instead of invalidating the entire product list.
Mistake 6: Forgetting error handling
API requests can fail because of network problems, server errors, invalid credentials, or unexpected responses.
Always design appropriate loading, error, and retry behavior for your application.
Conclusion
RTK Query simplifies API data management in Redux Toolkit applications.
We’ve learned that:
- RTK Query is designed for data fetching and caching.
createApi()defines API endpoints and generates hooks.fetchBaseQuery()provides a lightweight Fetch API wrapper.- Queries retrieve data.
- Mutations create, update, or delete data.
- The API reducer and middleware must be registered in the Redux store.
- RTK Query caches query results and can share them across components.
providesTagsandinvalidatesTagshelp synchronize cached data after mutations.createAsyncThunk()and RTK Query solve different asynchronous data-management problems.
The key takeaway is simple:
RTK Query helps you manage server data without manually rebuilding fetching, caching, loading-state, and cache-invalidation logic for every API endpoint.
Once you understand these concepts, you can use RTK Query to build cleaner and more maintainable React applications.
What’s Next?
Now that you understand RTK Query, here are some topics worth exploring next:
- RTK Query with TypeScript: Type your API responses, query arguments, and mutations.
- Cache Invalidation in Depth: Learn how to invalidate individual records and lists.
- RTK Query Pagination: Fetch data in pages or implement infinite scrolling.
- Optimistic Updates: Update the UI before the server confirms a mutation.
- RTK Query Authentication: Attach access tokens to requests and handle authentication errors.
- RTK Query vs React Query: Compare two popular server-state management solutions.
Happy coding! ✌️









