Back to Blog
React15 min read

Redux Toolkit RTK Query – Complete Guide

Learn RTK Query for React data fetching and caching. Complete guide with API setup, cache management, mutations, and best practices. Step-by-step tutorial with examples.

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.

In this guide, I'll walk you through using RTK Query in a real application. We'll set up an API slice, create query endpoints for fetching data, implement mutations for creating and updating records, handle cache invalidation, and manage loading and error states. By the end, you'll understand why RTK Query has become my go-to solution for API data management in React applications.

Installation

npm install @reduxjs/toolkit react-redux

Setting Up RTK Query API Slice

Creating 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;

Configuring 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;

Using RTK Query Hooks

Using the generated hooks in components:

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> {products.map((product) => ( <div key={product.id}> <h3>{product.name}</h3> <button onClick={() => handleDelete(product.id)} disabled={isDeleting}> Delete </button> </div> ))} </div> ); }

Advanced Features

Polling

const { data } = useGetProductsQuery( {}, { pollingInterval: 5000, // Poll every 5 seconds } );

Conditional Queries

const { data } = useGetProductByIdQuery(productId, { skip: !productId, // Skip query if productId is falsy });

Best Practices

  • Use tag-based cache invalidation for automatic refetching
  • Implement optimistic updates for better UX
  • Use skip option for conditional queries
  • Configure baseQuery with authentication headers
  • Organize API slices by feature domain
  • Use unwrap() for mutation error handling
  • Implement proper error handling in components

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.