RTK Query
RTK Query is a powerful data fetching and caching layer that ships inside Redux Toolkit. It eliminates the hand-written thunks, loading flags, and cache bookkeeping that traditionally surround server state in Redux. You describe your API once with createApi, and RTK Query generates typed React hooks that handle requests, caching, deduplication, re-fetching, and invalidation for you. Think of it as a Redux-native alternative to TanStack Query that integrates directly with your existing store.
Why RTK Query exists
Most “global state” in a typical app is not really client state at all — it is server state: data that lives in a database and is fetched over the network. Server state is asynchronous, can go stale, and is shared across components. Managing it manually with useEffect and reducers leads to duplicated requests, scattered loading booleans, and brittle cache invalidation. RTK Query treats server data as a first-class cached resource so you can stop writing that boilerplate.
Defining an API with createApi
An API “slice” is created once. You give it a unique reducerPath, a baseQuery (usually fetchBaseQuery, a thin wrapper around fetch), and a set of endpoints.
// src/services/postsApi.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com/' }),
tagTypes: ['Post'],
endpoints: (builder) => ({
getPosts: builder.query({
query: () => 'posts',
providesTags: ['Post'],
}),
getPost: builder.query({
query: (id) => `posts/${id}`,
providesTags: (result, error, id) => [{ type: 'Post', id }],
}),
addPost: builder.mutation({
query: (body) => ({ url: 'posts', method: 'POST', body }),
invalidatesTags: ['Post'],
}),
}),
});
export const { useGetPostsQuery, useGetPostQuery, useAddPostMutation } = postsApi;
RTK Query auto-generates a hook for every endpoint, following the pattern use<EndpointName><Query|Mutation>. You never write these hooks yourself.
Wiring the API into the store
Add the generated reducer and the API middleware to your Redux store. The middleware powers caching, invalidation, and features like polling.
// src/store.js
import { configureStore } from '@reduxjs/toolkit';
import { postsApi } from './services/postsApi';
export const store = configureStore({
reducer: {
[postsApi.reducerPath]: postsApi.reducer,
},
middleware: (getDefault) => getDefault().concat(postsApi.middleware),
});
Wrap your app in the standard Redux <Provider store={store}> and you are ready to consume the hooks.
Queries and mutations in components
Query hooks return the cached data plus rich status flags. Mutation hooks return a trigger function and a result object.
import { useGetPostsQuery, useAddPostMutation } from './services/postsApi';
function Posts() {
const { data: posts, isLoading, isError, refetch } = useGetPostsQuery();
const [addPost, { isLoading: isSaving }] = useAddPostMutation();
if (isLoading) return <p>Loading…</p>;
if (isError) return <button onClick={refetch}>Retry</button>;
return (
<section>
<button
disabled={isSaving}
onClick={() => addPost({ title: 'Hello', body: 'World', userId: 1 })}
>
{isSaving ? 'Saving…' : 'Add post'}
</button>
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
</section>
);
}
After addPost succeeds, the invalidatesTags: ['Post'] declaration tells RTK Query to mark the cached getPosts result as stale and automatically re-fetch it — no manual cache update required.
Caching and tag-based invalidation
RTK Query caches each query by its serialized arguments. Multiple components requesting the same data share one request and one cache entry. Cached data is kept for a configurable time after the last subscriber unmounts (keepUnusedDataFor, 60 seconds by default), then garbage-collected.
Invalidation is driven by tags. Queries declare what they provide with providesTags; mutations declare what they invalidate with invalidatesTags. When the tags overlap, the affected queries automatically re-fetch.
| Concept | Where it is set | Purpose |
|---|---|---|
tagTypes | createApi | The vocabulary of tags this API uses |
providesTags | query endpoint | Labels the cache entry this query fills |
invalidatesTags | mutation endpoint | Marks matching cache entries stale |
keepUnusedDataFor | endpoint or API | Seconds to retain unused cache data |
Tip: Use per-id tags like
{ type: 'Post', id }for granular invalidation. A mutation that edits one post can invalidate only that post instead of the entire list.
You can inspect the generated status flags returned by every query hook:
Output:
{
data: [...],
isLoading: false, // true only on the first load
isFetching: false, // true on any in-flight request (incl. refetch)
isSuccess: true,
isError: false,
refetch: fn
}
RTK Query vs TanStack Query
Both libraries solve server-state caching, but they target different ecosystems.
| Aspect | RTK Query | TanStack Query |
|---|---|---|
| Setup | Endpoints declared up front in createApi | Query functions defined inline per call |
| Store | Lives inside the Redux store | Standalone, store-agnostic |
| Hooks | Auto-generated per endpoint | Generic useQuery / useMutation |
| Cache key | Endpoint + serialized args | The query key array you pass |
| Best fit | Apps already using Redux Toolkit | Apps without Redux, or wanting maximal flexibility |
If your app already uses Redux Toolkit, RTK Query is the natural choice — it shares the store, devtools, and middleware. If you have no Redux and want the leanest server-cache layer, TanStack Query is excellent. They overlap heavily; pick one rather than running both.
Best Practices
- Create a single API slice per backend and add endpoints to it, rather than many tiny
createApiinstances. - Use specific per-id tags so mutations invalidate only the entries they actually change.
- Reach for the generated hooks (
useGetPostsQuery) instead of dispatching thunks manually. - Keep server state in RTK Query and client-only UI state in regular Redux slices or local state.
- Prefer
isFetchingfor background refresh spinners andisLoadingfor the very first load. - Use
transformResponseto normalize payloads at the endpoint boundary instead of in components. - Enable polling or
refetchOnFocusonly where freshness genuinely matters, to avoid needless requests.