DEV Community

Cover image for RTK Query: Complete Guide for React Data Fetching and Caching
Md. Maruf Rahman
Md. Maruf Rahman

Posted on • Originally published at marufrahman.live

RTK Query: Complete Guide for React Data Fetching and Caching

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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" },
    ],
  }),
})
Enter fullscreen mode Exit fullscreen mode

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) => [...],
}),
Enter fullscreen mode Exit fullscreen mode

Advanced Features

Polling

Poll data at regular intervals:

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

Conditional Queries

Skip queries conditionally:

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

Manual Refetching

Manually trigger a refetch:

const { data, refetch } = useGetProductsQuery({});

// Later in your component
<button onClick={() => refetch()}>Refresh</button>
Enter fullscreen mode Exit fullscreen mode

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 },
  ],
}),
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Use tag-based cache invalidation - Leverage providesTags and invalidatesTags for automatic refetching
  2. Implement optimistic updates - Update UI immediately for better UX
  3. Use skip option - Skip queries conditionally to avoid unnecessary requests
  4. Configure baseQuery with authentication - Add auth headers in prepareHeaders
  5. Organize API slices by feature - Create separate API slices for different domains
  6. Use unwrap() for mutations - Provides cleaner error handling
  7. Implement proper error handling - Handle errors gracefully in components
  8. Use TypeScript - Leverage full type safety with TypeScript
  9. Configure cache time - Use keepUnusedDataFor to control cache duration
  10. 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");
  }
};
Enter fullscreen mode Exit fullscreen mode

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>;
  }
}
Enter fullscreen mode Exit fullscreen mode

Resources and Further Reading

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)