RTK Query — Data Fetching Cache Built into Redux

Writing async thunks, handling pending/fulfilled/rejected states, and managing cache invalidation by hand is tedious — it is the same pattern repeated for every API endpoint. RTK Query is a powerful data fetching and caching layer built directly into Redux Toolkit that eliminates this boilerplate. You define your API endpoints once in a createApi call, and RTK Query automatically generates React hooks (useGetPostsQuery, useCreatePostMutation), manages the loading/error/data state, caches results, and invalidates stale data when mutations run.

Defining an RTK Query API Service

// src/store/apiSlice.js
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";

export const blogApi = createApi({
    reducerPath: "blogApi",   // key in the Redux store
    baseQuery: fetchBaseQuery({
        baseUrl: "/api",
        prepareHeaders: (headers, { getState }) => {
            // Attach auth token from Redux store
            const token = getState().auth.accessToken;
            if (token) headers.set("Authorization", `Bearer ${token}`);
            return headers;
        },
    }),

    tagTypes: ["Post", "User"],   // cache tag types for invalidation

    endpoints: (builder) => ({
        // ── Queries (GET) ──────────────────────────────────────────────────────
        getPosts: builder.query({
            query: ({ page = 1, tag = null, pageSize = 10 } = {}) => ({
                url:    "/posts",
                params: { page, page_size: pageSize, ...(tag ? { tag } : {}) },
            }),
            providesTags: (result) =>
                result
                    ? [
                          ...result.items.map(({ id }) => ({ type: "Post", id })),
                          { type: "Post", id: "LIST" },
                      ]
                    : [{ type: "Post", id: "LIST" }],
        }),

        getPostById: builder.query({
            query: (postId) => `/posts/${postId}`,
            providesTags: (result, error, postId) => [{ type: "Post", id: postId }],
        }),

        // ── Mutations (POST / PATCH / DELETE) ──────────────────────────────────
        createPost: builder.mutation({
            query: (postData) => ({
                url:    "/posts",
                method: "POST",
                body:   postData,
            }),
            invalidatesTags: [{ type: "Post", id: "LIST" }],
            // Invalidating "Post:LIST" refetches getPosts after a new post is created
        }),

        updatePost: builder.mutation({
            query: ({ id, ...patch }) => ({
                url:    `/posts/${id}`,
                method: "PATCH",
                body:   patch,
            }),
            invalidatesTags: (result, error, { id }) => [{ type: "Post", id }],
        }),

        deletePost: builder.mutation({
            query: (postId) => ({ url: `/posts/${postId}`, method: "DELETE" }),
            invalidatesTags: (result, error, postId) => [
                { type: "Post", id: postId },
                { type: "Post", id: "LIST" },
            ],
        }),

        likePost: builder.mutation({
            query: (postId) => ({ url: `/posts/${postId}/like`, method: "POST" }),
            invalidatesTags: (result, error, postId) => [{ type: "Post", id: postId }],
        }),
    }),
});

// Export auto-generated hooks
export const {
    useGetPostsQuery,
    useGetPostByIdQuery,
    useCreatePostMutation,
    useUpdatePostMutation,
    useDeletePostMutation,
    useLikePostMutation,
} = blogApi;
Note: RTK Query’s tag-based cache invalidation is its killer feature. When createPost completes, it invalidates the { type: "Post", id: "LIST" } tag. Any component using useGetPostsQuery (which providesTags includes { type: "Post", id: "LIST" }) automatically refetches its data in the background. This solves cache coherence without writing any imperative refetch logic — you declare relationships between mutations and queries, and RTK Query handles the rest.
Tip: Add the RTK Query API reducer and middleware to configureStore: reducer: { [blogApi.reducerPath]: blogApi.reducer } and middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(blogApi.middleware). The middleware handles cache timing, request deduplication, and garbage collection of unused cached data. Forgetting the middleware is the most common setup mistake — without it, cache invalidation and automatic refetching do not work.
Warning: RTK Query caches by the full cache key — the endpoint name plus the arguments. useGetPostsQuery({ page: 1 }) and useGetPostsQuery({ page: 2 }) are separate cache entries. If you render the same query with slightly different argument objects on different renders (e.g., { page: currentPage } where a new object is created each time), RTK Query handles this correctly using deep equality for cache key comparison. However, passing non-serializable values (class instances, functions) as arguments breaks the cache key — always use plain objects and primitives.

Adding RTK Query to the Store

// src/store/index.js (updated)
import { configureStore } from "@reduxjs/toolkit";
import authReducer         from "./authSlice";
import uiReducer           from "./uiSlice";
import { blogApi }         from "./apiSlice";

export const store = configureStore({
    reducer: {
        auth: authReducer,
        ui:   uiReducer,
        [blogApi.reducerPath]: blogApi.reducer,   // ← RTK Query cache
    },
    middleware: (getDefault) =>
        getDefault().concat(blogApi.middleware),  // ← required for cache features
});

Using RTK Query Hooks in Components

import { useGetPostsQuery, useDeletePostMutation } from "@/store/apiSlice";

function PostFeed({ page, tag }) {
    // Auto-fetches, caches, and refetches when page/tag changes
    const { data, isLoading, isError, error } = useGetPostsQuery({ page, tag });

    if (isLoading) return <PostListSkeleton />;
    if (isError)   return <ErrorMessage message={error?.data?.detail} />;

    return (
        <div>
            <PostList posts={data?.items ?? []} />
            <Pagination page={page} total={data?.total ?? 0} />
        </div>
    );
}

function DeleteButton({ postId }) {
    const [deletePost, { isLoading }] = useDeletePostMutation();

    async function handleDelete() {
        if (!confirm("Delete this post?")) return;
        await deletePost(postId);
        // Automatically invalidates and refetches the posts list!
        // No manual refetch needed.
    }

    return (
        <button onClick={handleDelete} disabled={isLoading}>
            {isLoading ? "Deleting..." : "Delete"}
        </button>
    );
}

Common Mistakes

Mistake 1 — Forgetting blogApi.middleware in configureStore

❌ Wrong — cache invalidation and refetch do not work:

configureStore({ reducer: { [blogApi.reducerPath]: blogApi.reducer } });
// No middleware! Mutations do not trigger refetches.

✅ Correct — always add the middleware.

Mistake 2 — Not using providesTags/invalidatesTags (stale cache)

❌ Wrong — creating a post does not refresh the list:

createPost: builder.mutation({ query: (data) => ({ url: "/posts", method: "POST", body: data }) });
// No invalidatesTags — getPosts cache never invalidated!

✅ Correct — add invalidatesTags: [{ type: "Post", id: "LIST" }].

Quick Reference

Concept RTK Query API
Create API createApi({ reducerPath, baseQuery, tagTypes, endpoints })
Query (GET) builder.query({ query, providesTags })
Mutation (POST/PATCH) builder.mutation({ query, invalidatesTags })
Use query const { data, isLoading, isError } = useGetPostsQuery(args)
Use mutation const [mutate, { isLoading }] = useCreatePostMutation()
Auth header prepareHeaders: (headers, { getState }) => { ... }

🧠 Test Yourself

After calling deletePost(42), the posts list still shows the deleted post. What is the most likely cause?