RTK Query is a powerful data fetching and caching library built on top of Redux Toolkit. It simplifies API data management in React applications by providing automatic caching, request deduplication, and optimistic updates. Managing API data in React applications used to be a pain - I'd write custom hooks for fetching, manually handle loading states, implement caching logic, and deal with race conditions. Then I discovered RTK Query, and it eliminated about 80% of the boilerplate code I was writing for data fetching.
RTK Query is built on top of Redux Toolkit, but you don't need to understand Redux to use it. It provides automatic caching (so you don't refetch data unnecessarily), request deduplication (multiple components requesting the same data only triggers one API call), and optimistic updates (UI updates immediately while the request is in flight). It's like having a smart data fetching layer that handles all the edge cases for you.
π Want the complete guide with more examples and advanced patterns? Check out the full article on my blog for an in-depth tutorial with additional code examples, troubleshooting tips, and real-world use cases.
What is RTK Query?
RTK Query is a powerful data fetching and caching library built on top of Redux Toolkit. It provides:
- Automatic caching - Responses are cached automatically
- Request deduplication - Identical requests are deduplicated
- Background refetching - Data can be refetched in the background
- Optimistic updates - UI updates immediately while requests are in flight
- TypeScript support - Full TypeScript support out of the box
- Zero boilerplate - No need to write reducers, actions, or thunks
Installation
Installing RTK Query is straightforward:
npm install @reduxjs/toolkit react-redux
RTK Query is included in @reduxjs/toolkit, so you don't need a separate package.
Setting Up RTK Query API Slice
Creating an API slice is the foundation of RTK Query. Here's how to set up a products API slice:
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export const productsApiSlice = createApi({
reducerPath: "products",
baseQuery: fetchBaseQuery({
baseUrl: "http://localhost:3000/api",
prepareHeaders: (headers, { getState }) => {
const token = (getState() as any).auth?.token;
if (token) {
headers.set("authorization", `Bearer ${token}`);
}
return headers;
},
}),
tagTypes: ["Product"],
endpoints: (builder) => ({
getProducts: builder.query({
query: () => "/products",
providesTags: (result) =>
result?.data
? [
...result.data.map(({ id }: { id: string }) => ({
type: "Product" as const,
id
})),
{ type: "Product", id: "LIST" },
]
: [{ type: "Product", id: "LIST" }],
}),
getProductById: builder.query({
query: (id: string) => "/products/" + id,
providesTags: (result, error, id) => [{ type: "Product", id }],
}),
addProduct: builder.mutation({
query: (productData) => ({
url: "/products",
method: "POST",
body: productData,
formData: true,
}),
invalidatesTags: [{ type: "Product", id: "LIST" }],
}),
updateProduct: builder.mutation({
query: ({ id, formData }) => ({
url: "/products/" + id,
method: "PUT",
body: formData,
formData: true,
}),
invalidatesTags: (result, error, arg) => [
{ type: "Product", id: arg.id },
{ type: "Product", id: "LIST" },
],
}),
deleteProduct: builder.mutation({
query: (id) => ({
url: "/products/" + id,
method: "DELETE",
}),
invalidatesTags: (result, error, id) => [
{ type: "Product", id },
{ type: "Product", id: "LIST" },
],
}),
}),
});
export const {
useGetProductsQuery,
useGetProductByIdQuery,
useAddProductMutation,
useUpdateProductMutation,
useDeleteProductMutation,
} = productsApiSlice;
Key Concepts
-
createApi- Creates an API slice with endpoints -
fetchBaseQuery- Base query function for making HTTP requests -
tagTypes- Defines cache tags for invalidation -
providesTags- Tags cached data for automatic invalidation -
invalidatesTags- Invalidates cache when mutations occur -
builder.query- Creates query endpoints (GET requests) -
builder.mutation- Creates mutation endpoints (POST, PUT, DELETE)
Configuring Redux Store
Add the API slice to your Redux store:
import { configureStore } from "@reduxjs/toolkit";
import { productsApiSlice } from "./products/productSlice";
import { categoriesApiSlice } from "./categories/categorySlice";
export const store = configureStore({
reducer: {
[productsApiSlice.reducerPath]: productsApiSlice.reducer,
[categoriesApiSlice.reducerPath]: categoriesApiSlice.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(
productsApiSlice.middleware,
categoriesApiSlice.middleware
),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Provider Setup
Wrap your app with the Redux Provider:
import { Provider } from "react-redux";
import { store } from "./store";
function App() {
return (
<Provider store={store}>
<YourApp />
</Provider>
);
}
Using RTK Query Hooks
RTK Query automatically generates hooks for each endpoint. Here's how to use them:
Query Hooks
import { useGetProductsQuery, useGetProductByIdQuery } from "../../state/products/productSlice";
function Products() {
const { data, isLoading, isError, error, refetch } = useGetProductsQuery({});
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error: {error.message}</div>;
const products = data?.data || [];
return (
<div>
{products.map((product) => (
<div key={product.id}>
<h3>{product.name}</h3>
</div>
))}
</div>
);
}
Mutation Hooks
import { useDeleteProductMutation, useAddProductMutation } from "../../state/products/productSlice";
function ProductActions({ productId }: { productId: string }) {
const [deleteProduct, { isLoading: isDeleting }] = useDeleteProductMutation();
const [addProduct, { isLoading: isAdding }] = useAddProductMutation();
const handleDelete = async () => {
try {
await deleteProduct(productId).unwrap();
toast.success("Product deleted successfully");
} catch (error) {
toast.error("Failed to delete product");
}
};
const handleAdd = async (productData: FormData) => {
try {
const result = await addProduct(productData).unwrap();
toast.success("Product added successfully");
return result;
} catch (error) {
toast.error("Failed to add product");
throw error;
}
};
return (
<div>
<button onClick={handleDelete} disabled={isDeleting}>
Delete
</button>
</div>
);
}
Using unwrap() for Error Handling
The unwrap() method provides cleaner error handling:
const handleAddProduct = async (productData: FormData) => {
try {
// unwrap() returns the actual data or throws an error
const result = await addProduct(productData).unwrap();
console.log("Product added:", result);
return result;
} catch (error) {
// Error is automatically thrown and can be caught
console.error("Failed to add product:", error);
throw error;
}
};
Cache Management
RTK Query provides powerful cache management through tags. Here's how it works:
Cache Tags
endpoints: (builder) => ({
getProducts: builder.query({
query: () => "/products",
// Tag the cached data
providesTags: (result) =>
result?.data
? [
// Tag each individual product
...result.data.map(({ id }) => ({ type: "Product", id })),
// Tag the list
{ type: "Product", id: "LIST" },
]
: [{ type: "Product", id: "LIST" }],
}),
addProduct: builder.mutation({
query: (productData) => ({
url: "/products",
method: "POST",
body: productData,
}),
// Invalidate the list when a product is added
invalidatesTags: [{ type: "Product", id: "LIST" }],
}),
updateProduct: builder.mutation({
query: ({ id, formData }) => ({
url: "/products/" + id,
method: "PUT",
body: formData,
}),
// Invalidate both the specific product and the list
invalidatesTags: (result, error, arg) => [
{ type: "Product", id: arg.id },
{ type: "Product", id: "LIST" },
],
}),
})
Cache Configuration
You can configure cache behavior per endpoint:
getProducts: builder.query({
query: () => "/products",
// Keep unused data for 60 seconds
keepUnusedDataFor: 60,
// Refetch on mount or when args change
refetchOnMountOrArgChange: true,
// Refetch on reconnect
refetchOnReconnect: true,
providesTags: (result) => [...],
}),
Advanced Features
Polling
Poll data at regular intervals:
const { data } = useGetProductsQuery(
{},
{
pollingInterval: 5000, // Poll every 5 seconds
}
);
Conditional Queries
Skip queries conditionally:
const { data } = useGetProductByIdQuery(productId, {
skip: !productId, // Skip query if productId is falsy
});
Manual Refetching
Manually trigger a refetch:
const { data, refetch } = useGetProductsQuery({});
// Later in your component
<button onClick={() => refetch()}>Refresh</button>
Optimistic Updates
Update the UI immediately while the request is in flight:
updateProduct: builder.mutation({
query: ({ id, formData }) => ({
url: "/products/" + id,
method: "PUT",
body: formData,
}),
// Optimistically update the cache
onQueryStarted: async ({ id, formData }, { dispatch, queryFulfilled }) => {
// Optimistic update
const patchResult = dispatch(
productsApiSlice.util.updateQueryData("getProductById", id, (draft) => {
Object.assign(draft, formData);
})
);
try {
await queryFulfilled;
} catch {
// Revert on error
patchResult.undo();
}
},
invalidatesTags: (result, error, arg) => [
{ type: "Product", id: arg.id },
],
}),
Complete Example: Product Management
Here's a complete example combining queries and mutations:
import { useGetProductsQuery, useDeleteProductMutation } from "../../state/products/productSlice";
function Products() {
const { data, isLoading, isError, error } = useGetProductsQuery({});
const [deleteProduct, { isLoading: isDeleting }] = useDeleteProductMutation();
const handleDelete = async (id: string) => {
try {
await deleteProduct(id).unwrap();
toast.success("Product deleted successfully");
} catch (error) {
toast.error("Failed to delete product");
}
};
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error loading products</div>;
const products = data?.data || [];
return (
<div>
<h1>Products</h1>
{products.map((product) => (
<div key={product.id}>
<h3>{product.name}</h3>
<p>{product.description}</p>
<button
onClick={() => handleDelete(product.id)}
disabled={isDeleting}
>
Delete
</button>
</div>
))}
</div>
);
}
RTK Query vs Redux Thunk
| Feature | RTK Query | Redux Thunk |
|---|---|---|
| Caching | Automatic | Manual |
| Request Deduplication | Automatic | Manual |
| Loading States | Automatic | Manual |
| Error Handling | Built-in | Manual |
| Cache Invalidation | Tag-based | Manual |
| Background Refetching | Built-in | Manual |
| Optimistic Updates | Built-in | Manual |
| Boilerplate | Minimal | High |
RTK Query is a higher-level solution that handles data fetching, caching, and synchronization automatically. Redux Thunk requires manual implementation of these features.
Best Practices
-
Use tag-based cache invalidation - Leverage
providesTagsandinvalidatesTagsfor automatic refetching - Implement optimistic updates - Update UI immediately for better UX
-
Use
skipoption - Skip queries conditionally to avoid unnecessary requests -
Configure baseQuery with authentication - Add auth headers in
prepareHeaders - Organize API slices by feature - Create separate API slices for different domains
-
Use
unwrap()for mutations - Provides cleaner error handling - Implement proper error handling - Handle errors gracefully in components
- Use TypeScript - Leverage full type safety with TypeScript
-
Configure cache time - Use
keepUnusedDataForto control cache duration - Use polling for real-time data - Poll endpoints that need frequent updates
Common Patterns
Form Submission with File Upload
addProduct: builder.mutation({
query: (formData: FormData) => ({
url: "/products",
method: "POST",
body: formData,
formData: true, // Important for file uploads
}),
invalidatesTags: [{ type: "Product", id: "LIST" }],
}),
// Usage
const [addProduct] = useAddProductMutation();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
try {
await addProduct(formData).unwrap();
toast.success("Product added successfully");
} catch (error) {
toast.error("Failed to add product");
}
};
Error Handling
const { data, error, isError } = useGetProductsQuery({});
if (isError) {
if ('status' in error) {
// RTK Query error
const errMsg = 'error' in error ? error.error : JSON.stringify(error.data);
return <div>Error: {errMsg}</div>;
} else {
// Network error
return <div>Error: {error.message}</div>;
}
}
Resources and Further Reading
- π Full RTK Query Guide - Complete tutorial with advanced examples, troubleshooting, and best practices
- React Hook Form with Zod Validation - Learn form validation patterns that work great with RTK Query
- RTK Query Documentation - Official RTK Query documentation
- Redux Toolkit Documentation - Official Redux Toolkit docs
- RTK Query Examples - Official examples and patterns
- TanStack Table Implementation - Build data tables that work perfectly with RTK Query
- TypeScript with React Best Practices - TypeScript patterns for RTK Query
Conclusion
RTK Query provides a powerful, type-safe solution for API data management in React applications. With automatic caching, request deduplication, and optimistic updates, it simplifies complex data fetching scenarios. This makes it perfect for inventory management systems and other data-heavy applications.
Key Takeaways:
- RTK Query eliminates boilerplate code for data fetching
- Automatic caching reduces unnecessary API calls
- Request deduplication prevents duplicate requests
- Tag-based cache invalidation enables automatic refetching
- Optimistic updates improve user experience
- Full TypeScript support provides type safety
- Works seamlessly with Redux Toolkit
Whether you're building a simple data fetching feature or a complex system with real-time updates, polling, and optimistic updates, RTK Query provides the foundation you need. It's the modern way to handle API data in React applications.
What's your experience with RTK Query? Share your tips and tricks in the comments below! π
π‘ Looking for more details? This is a condensed version of my comprehensive guide. Read the full article on my blog for additional examples, advanced patterns, troubleshooting tips, and more in-depth explanations.
If you found this guide helpful, consider checking out my other articles on React development and frontend development best practices.
Top comments (0)