RTK Query is Redux Toolkit’s built-in data fetching and caching solution — createApi({ baseQuery: fetchBaseQuery({ baseUrl }), endpoints: (builder) => ({ ... }) }) defines a service. builder.query<ReturnType, ArgType>({ query: (id) => \/items/${id}` })creates a GET endpoint.builder.mutation<ReturnType, ArgType>({ query: (body) => ({ url, method: “POST”, body }) })creates POST/PUT/DELETE. Generated hooks:useGetItemsQuery(), useGetItemQuery(id), useCreateItemMutation(). providesTags: [“Item”]marks what a query caches.invalidatesTags: [“Item”]clears matching caches after a mutation.transformResponse: (response) => response.datareshapes API responses.onQueryStartedimplements optimistic updates viaupdateQueryData. pollingInterval: 10000refetches every 10 seconds.api.util.prefetchpre-loads data.injectEndpoints` enables code-splitting. Claude Code generates RTK Query CRUD services, optimistic list updates, pagination, and mutation with rollback.
CLAUDE.md for RTK Query
## RTK Query Stack
- Version: @reduxjs/toolkit >= 2.3
- API: const api = createApi({ reducerPath: "api", baseQuery: fetchBaseQuery({ baseUrl: "/api/", prepareHeaders: (headers, { getState }) => { headers.set("authorization", `Bearer ${selectToken(getState())}`) } }), endpoints: ... })
- Query: builder.query<Product[], void>({ query: () => "products", providesTags: ["Product"] })
- Mutation: builder.mutation<Product, Partial<Product>>({ query: (body) => ({ url: "products", method: "POST", body }), invalidatesTags: ["Product"] })
- Hooks: const { data, isLoading, error } = useGetProductsQuery(); const [create, { isLoading: creating }] = useCreateProductMutation()
- Store: configureStore({ reducer: { [api.reducerPath]: api.reducer }, middleware: (gDM) => gDM().concat(api.middleware) })
API Service
// store/api/productsApi.ts — RTK Query product service
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"
import type { RootState } from "@/store"
export type Product = {
id: string
name: string
description: string
price: number
stock: number
categoryId: string
imageUrl: string
isActive: boolean
createdAt: string
}
export type CreateProductInput = Omit<Product, "id" | "createdAt">
export type UpdateProductInput = Partial<CreateProductInput> & { id: string }
type PaginatedProducts = {
items: Product[]
total: number
page: number
pageSize: number
}
type ProductFilters = {
page?: number
pageSize?: number
categoryId?: string
minPrice?: number
maxPrice?: number
search?: string
isActive?: boolean
}
export const productsApi = createApi({
reducerPath: "productsApi",
baseQuery: fetchBaseQuery({
baseUrl: "/api/",
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth?.token
if (token) headers.set("authorization", `Bearer ${token}`)
return headers
},
}),
tagTypes: ["Product", "ProductDetail"],
endpoints: (builder) => ({
// GET paginated list
getProducts: builder.query<PaginatedProducts, ProductFilters>({
query: (filters = {}) => ({
url: "products",
params: filters,
}),
providesTags: (result) =>
result
? [
...result.items.map(({ id }) => ({ type: "Product" as const, id })),
{ type: "Product", id: "LIST" },
]
: [{ type: "Product", id: "LIST" }],
}),
// GET single product
getProduct: builder.query<Product, string>({
query: (id) => `products/${id}`,
providesTags: (_, __, id) => [{ type: "ProductDetail", id }],
}),
// POST create
createProduct: builder.mutation<Product, CreateProductInput>({
query: (body) => ({
url: "products",
method: "POST",
body,
}),
invalidatesTags: [{ type: "Product", id: "LIST" }],
}),
// PUT update with optimistic update
updateProduct: builder.mutation<Product, UpdateProductInput>({
query: ({ id, ...body }) => ({
url: `products/${id}`,
method: "PUT",
body,
}),
// Optimistic update
async onQueryStarted({ id, ...patch }, { dispatch, queryFulfilled }) {
const patchResult = dispatch(
productsApi.util.updateQueryData("getProduct", id, (draft) => {
Object.assign(draft, patch)
}),
)
try {
await queryFulfilled
} catch {
patchResult.undo()
}
},
invalidatesTags: (_, __, { id }) => [
{ type: "Product", id },
{ type: "ProductDetail", id },
],
}),
// DELETE
deleteProduct: builder.mutation<void, string>({
query: (id) => ({
url: `products/${id}`,
method: "DELETE",
}),
invalidatesTags: (_, __, id) => [
{ type: "Product", id },
{ type: "Product", id: "LIST" },
],
}),
// PATCH toggle active — updates list optimistically
toggleProductActive: builder.mutation<Product, { id: string; isActive: boolean }>({
query: ({ id, isActive }) => ({
url: `products/${id}/active`,
method: "PATCH",
body: { isActive },
}),
async onQueryStarted({ id, isActive }, { dispatch, queryFulfilled, getState }) {
// Update all cached list queries that contain this product
const patches: Array<ReturnType<typeof productsApi.util.updateQueryData>> = []
for (const { endpointName, originalArgs } of productsApi.util.selectInvalidatedBy(
getState(),
[{ type: "Product", id }],
)) {
if (endpointName === "getProducts") {
patches.push(
dispatch(
productsApi.util.updateQueryData("getProducts", originalArgs, (draft) => {
const item = draft.items.find(p => p.id === id)
if (item) item.isActive = isActive
}),
),
)
}
}
try {
await queryFulfilled
} catch {
patches.forEach(p => p.undo())
}
},
invalidatesTags: (_, __, { id }) => [{ type: "Product", id }],
}),
}),
})
export const {
useGetProductsQuery,
useGetProductQuery,
useCreateProductMutation,
useUpdateProductMutation,
useDeleteProductMutation,
useToggleProductActiveMutation,
} = productsApi
Store Setup
// store/index.ts — Redux store with RTK Query
import { configureStore } from "@reduxjs/toolkit"
import { productsApi } from "./api/productsApi"
import authReducer from "./slices/authSlice"
export const store = configureStore({
reducer: {
auth: authReducer,
[productsApi.reducerPath]: productsApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(productsApi.middleware),
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
React Components
// components/products/ProductsPage.tsx — full CRUD UI
"use client"
import { useState } from "react"
import {
useGetProductsQuery,
useDeleteProductMutation,
useToggleProductActiveMutation,
} from "@/store/api/productsApi"
export function ProductsPage() {
const [page, setPage] = useState(1)
const [search, setSearch] = useState("")
const { data, isLoading, isFetching } = useGetProductsQuery({
page,
pageSize: 20,
search: search || undefined,
})
const [deleteProduct] = useDeleteProductMutation()
const [toggleActive] = useToggleProductActiveMutation()
if (isLoading) {
return (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-16 rounded-xl bg-muted animate-pulse" />
))}
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<input
type="search"
placeholder="Search products..."
value={search}
onChange={e => { setSearch(e.target.value); setPage(1) }}
className="input flex-1"
/>
{isFetching && (
<div className="size-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
)}
</div>
<div className="space-y-2">
{data?.items.map(product => (
<div key={product.id} className="flex items-center gap-4 p-4 rounded-xl border">
<img src={product.imageUrl} alt="" className="size-12 rounded-lg object-cover" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{product.name}</p>
<p className="text-sm text-muted-foreground">${(product.price / 100).toFixed(2)}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => toggleActive({ id: product.id, isActive: !product.isActive })}
className={`text-xs px-2 py-1 rounded-full ${product.isActive ? "bg-green-100 text-green-700" : "bg-muted text-muted-foreground"}`}
>
{product.isActive ? "Active" : "Inactive"}
</button>
<button
onClick={() => deleteProduct(product.id)}
className="text-sm text-destructive hover:opacity-70 px-2 py-1"
>
Delete
</button>
</div>
</div>
))}
</div>
{/* Pagination */}
{data && data.total > 20 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{data.total} products
</p>
<div className="flex gap-2">
<button onClick={() => setPage(p => p - 1)} disabled={page === 1} className="btn-ghost text-sm px-3 py-1.5 disabled:opacity-40">← Prev</button>
<span className="text-sm px-3 py-1.5">Page {page}</span>
<button onClick={() => setPage(p => p + 1)} disabled={page * 20 >= data.total} className="btn-ghost text-sm px-3 py-1.5 disabled:opacity-40">Next →</button>
</div>
</div>
)}
</div>
)
}
For the TanStack Query (React Query) alternative when you don’t use Redux and want a standalone server-state library without the Redux boilerplate, store setup, and provider nesting — TanStack Query has a simpler API and is a better fit when you only need server-state caching without Redux’s client state management, see the TanStack Query guide. For the SWR alternative when a minimal, opinionated data-fetching library with automatic revalidation, focus-revalidation, and a tiny bundle footprint is preferred over RTK Query’s Redux integration — SWR requires no store setup and suits simple fetching without CRUD mutation patterns, see the SWR guide. The Claude Skills 360 bundle includes RTK Query skill sets covering endpoints, optimistic updates, and cache invalidation. Start with the free tier to try Redux data fetching generation.