RTK Query
Every real application fetches data. Without a dedicated tool, you end up writing the same loading/error/cache boilerplate dozens of times — and inevitably getting it wrong somewhere. RTK Query is a powerful data-fetching and caching layer built directly into Redux Toolkit. It generates React hooks automatically from your API definition and handles caching, deduplication, background refetching, and cache invalidation out of the box.
Core Concepts
createApi— defines the API: base URL, endpoints (queries and mutations).Query endpoint — reads data. Generates a
useGetXxxQueryhook.Mutation endpoint — writes data. Generates a
useAddXxxMutationhook.providesTags/invalidatesTags— cache invalidation: a mutation can invalidate a query's cache, triggering an automatic refetch.fetchBaseQuery— a thinfetchwrapper that handles base URL and headers. Swap it foraxiosor any custom function.
Creating an API Slice
createApi defines all your endpoints in one place. Here is a complete CRUD API for a users resource:
// src/features/users/usersApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
interface User {
id: number
name: string
email: string
}
interface NewUser {
name: string
email: string
}
export const usersApi = createApi({
reducerPath: 'usersApi', // key in the Redux store
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
// tagTypes declares the cache tags this API uses for invalidation
tagTypes: ['User'],
endpoints: builder => ({
// ── Queries (read data) ──────────────────────────────────────────
getUsers: builder.query<User[], void>({
query: () => '/users',
// This query's cache is tagged as 'User' + each user's id.
// Any mutation that invalidatesTags(['User']) will refetch this.
providesTags: (result) =>
result
? [
...result.map(u => ({ type: 'User' as const, id: u.id })),
{ type: 'User', id: 'LIST' },
]
: [{ type: 'User', id: 'LIST' }],
}),
getUserById: builder.query<User, number>({
query: (id) => `/users/${id}`,
providesTags: (_result, _err, id) => [{ type: 'User', id }],
}),
// ── Mutations (write data) ────────────────────────────────────────
addUser: builder.mutation<User, NewUser>({
query: (newUser) => ({
url: '/users',
method: 'POST',
body: newUser,
}),
// Invalidate the LIST tag so getUsers refetches automatically
invalidatesTags: [{ type: 'User', id: 'LIST' }],
}),
updateUser: builder.mutation<User, Partial<User> & Pick<User, 'id'>>({
query: ({ id, ...patch }) => ({
url: `/users/${id}`,
method: 'PATCH',
body: patch,
}),
// Invalidate this specific user's cache entry
invalidatesTags: (_result, _err, { id }) => [{ type: 'User', id }],
}),
deleteUser: builder.mutation<void, number>({
query: (id) => ({
url: `/users/${id}`,
method: 'DELETE',
}),
invalidatesTags: (_result, _err, id) => [
{ type: 'User', id },
{ type: 'User', id: 'LIST' },
],
}),
}),
})
// Auto-generated hooks — one per endpoint
export const {
useGetUsersQuery,
useGetUserByIdQuery,
useAddUserMutation,
useUpdateUserMutation,
useDeleteUserMutation,
} = usersApiRegistering the API with the Store
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import { usersApi } from '../features/users/usersApi'
export const store = configureStore({
reducer: {
// RTK Query manages its own slice in the Redux store
[usersApi.reducerPath]: usersApi.reducer,
},
// setupListeners enables refetchOnFocus and refetchOnReconnect
middleware: getDefault =>
getDefault().concat(usersApi.middleware),
})
// Optional: enable automatic refetch when the window regains focus
import { setupListeners } from '@reduxjs/toolkit/query'
setupListeners(store.dispatch)Using Query Hooks
import { useGetUsersQuery } from './usersApi'
function UserList() {
const {
data: users,
isLoading,
isFetching, // true during background refetch (data still shown)
isError,
error,
refetch, // manually trigger a refetch
} = useGetUsersQuery()
if (isLoading) return <p>Loading users…</p>
if (isError) return <p>Error: {String(error)}</p>
return (
<>
{isFetching && <span>Refreshing…</span>}
<ul>
{users?.map(user => (
<li key={user.id}>{user.name} — {user.email}</li>
))}
</ul>
<button onClick={refetch}>Refresh</button>
</>
)
}Using Mutation Hooks
import { useState } from 'react'
import { useAddUserMutation, useDeleteUserMutation } from './usersApi'
function AddUserForm() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [addUser, { isLoading, isError }] = useAddUserMutation()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
try {
await addUser({ name, email }).unwrap() // throws on error
setName('')
setEmail('')
// getUsers refetches automatically because addUser invalidates the LIST tag
} catch (err) {
console.error('Failed to add user:', err)
}
}
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={e => setName(e.target.value)} placeholder="Name" />
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" />
<button type="submit" disabled={isLoading}>
{isLoading ? 'Adding…' : 'Add User'}
</button>
{isError && <p>Failed to add user.</p>}
</form>
)
}
function UserRow({ user }) {
const [deleteUser] = useDeleteUserMutation()
return (
<li>
{user.name}
<button onClick={() => deleteUser(user.id)}>Delete</button>
</li>
)
}Polling and Auto-Refetch
// Poll every 30 seconds
const { data } = useGetUsersQuery(undefined, {
pollingInterval: 30_000,
})
// Skip the query entirely (useful for conditional fetching)
const { data } = useGetUserByIdQuery(userId, {
skip: !userId,
})
// Refetch when the window regains focus (requires setupListeners)
const { data } = useGetUsersQuery(undefined, {
refetchOnFocus: true,
})
// Keep previous data while fetching new data (no loading flash on refetch)
const { data, isFetching } = useGetUsersQuery(page, {
keepPreviousData: true,
})Optimistic Updates
For a snappy UI, update the cache optimistically before the server confirms. RTK Query supports this with onQueryStarted:
updateUser: builder.mutation<User, Partial<User> & Pick<User, 'id'>>({
query: ({ id, ...patch }) => ({
url: `/users/${id}`,
method: 'PATCH',
body: patch,
}),
async onQueryStarted({ id, ...patch }, { dispatch, queryFulfilled }) {
// Optimistically update the cache
const patchResult = dispatch(
usersApi.util.updateQueryData('getUsers', undefined, draft => {
const user = draft.find(u => u.id === id)
if (user) Object.assign(user, patch)
})
)
try {
await queryFulfilled // wait for server confirmation
} catch {
// Server rejected — roll back the optimistic update
patchResult.undo()
}
},
})RTK Query vs TanStack Query
RTK Query | TanStack Query | |
|---|---|---|
Setup | Integrated into Redux store | Standalone, no Redux needed |
Best when | Already using Redux Toolkit | No Redux, or non-Redux projects |
Bundle size | Included in RTK (no extra dep) | Separate package (~13 KB gz) |
Cache invalidation | Tag-based (explicit) | Key-based (invalidateQueries) |
Optimistic updates | onQueryStarted + updateQueryData | onMutate + context rollback |
DevTools | Redux DevTools | Dedicated TanStack Query DevTools |
When RTK Query Is the Right Choice
Your app already uses Redux Toolkit for other state.
You want cache, loading state, background refetch, and invalidation with zero extra dependencies.
You need Redux DevTools to inspect cached server data alongside your client state.
You want a single
createApidefinition to serve as the source of truth for your API surface.