What is RTK Query in Redux Toolkit?
RTK Query is a data fetching and caching library built into Redux Toolkit that auto-generates hooks, manages cache, and handles loading states from a single createApi call.
Theory
TL;DR
- Think of it as a smart cache layer: define endpoints once, RTK Query handles fetch, store, and re-render automatically.
- Main difference from plain Redux: no manual actions, reducers, or thunks. RTK Query generates all of that from your endpoint definitions.
- Use it when your app has more than a few API endpoints. Skip it for pure client state (use
createSliceinstead). - Tags (
providesTags/invalidatesTags) are the core mechanism for keeping data fresh after mutations. - Works in non-React apps too, but you lose the auto-generated hooks.
Quick example
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const pokemonApi = createApi({
reducerPath: 'pokemonApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2' }),
endpoints: (builder) => ({
getPokemonByName: builder.query({
query: (name) => `pokemon/${name}`, // GET /pokemon/pikachu
}),
}),
});
export const { useGetPokemonByNameQuery } = pokemonApi; // hook is readyOne createApi call produces a Redux slice, a middleware entry, and a typed React hook. No extra files needed.
Key difference from manual Redux async
With plain Redux, fetching data means writing an async thunk, action creators, and reducer cases for loading/success/error. RTK Query replaces all of that. You describe what data you need and the endpoint URL. RTK Query builds the cache slice, subscribes to it when the hook mounts, and removes the subscription when the component unmounts. The result is significantly less code with the same reliability.
When to use
- App has 5+ API endpoints: RTK Query cache beats hand-rolled thunks.
- React app that already uses Redux: fits without adding another library.
- Need polling, optimistic updates, or tag-based invalidation: all built in.
- Pure client-side state with no server data: stick with
createSlice. - Non-React app: use the RTK Query core API directly, without hooks.
How it works internally
RTK Query creates a dedicated Redux slice that stores responses normalized by query key. When a component calls useGetPokemonByNameQuery('pikachu'), the hook dispatches a subscription action. If the cache already has a fresh response for that key, it returns immediately. If not, it fires the baseQuery (a fetch wrapper). The response lands in the Redux store, and useSyncExternalStore triggers a re-render.
Cache stays alive as long as at least one subscriber exists. When all components using a query unmount, RTK Query starts a countdown (60 seconds by default) and then drops the cached data. That is the garbage collection model.
Tag-based invalidation runs the other direction. A mutation marks certain tags as stale. Any query that declared providesTags with those tags gets refetched on the next render. This is how adding to a cart can automatically refresh the product list without any manual dispatch.
Auth tokens and headers
baseQuery: fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token;
if (token) headers.set('Authorization', `Bearer ${token}`);
return headers;
},
})prepareHeaders runs before every request. Reading from the Redux store here means the token stays current even after a refresh cycle.
Common mistakes
1. Forgetting providesTags on list queries.
// Wrong: mutations can never know to refetch this list
getUsers: builder.query({ query: () => 'users' })
// Correct: tag each item so mutations can invalidate precisely
getUsers: builder.query({
query: () => 'users',
providesTags: (result) =>
result
? [...result.map(({ id }) => ({ type: 'User', id })), { type: 'User', id: 'LIST' }]
: [{ type: 'User', id: 'LIST' }],
})Without tags the cache stays stale after a mutation. This is the mistake I see most often in code review on RTK Query PRs.
2. Using manual refetch() instead of invalidatesTags.
// Wrong: only refreshes this one query
const [refetch] = useLazyGetPostQuery(); refetch();
// Correct: invalidates every query that depends on Post
addPost: builder.mutation({
query: (body) => ({ url: 'posts', method: 'POST', body }),
invalidatesTags: ['Post'],
})Manual refetch breaks the dependency chain. Other queries that serve the same data stay stale.
3. Side effects inside queryFn.
// Wrong: mutates localStorage in a function that may run in parallel
queryFn: async () => { localStorage.clear(); return { data }; }baseQuery can run in parallel for multiple endpoints. Side effects here cause race conditions and SSR mismatches. Keep queryFn pure.
4. Not using skip when the argument is not ready.
// Wrong: fires a request with undefined in the URL
const { data } = useGetUserQuery(userId);
// Correct: wait until userId is available
const { data } = useGetUserQuery(userId, { skip: !userId });RTK Query uses the argument as a cache key. undefined is valid but it fetches with undefined in the URL, which is almost never what you want.
Real-world usage
- E-commerce: product list and cart queries linked via tags (add to cart refetches stock automatically).
- Admin dashboards:
pollingInterval: 30000for live order counts without WebSockets. - React Native: RTK Query cache persisted with
redux-persistfor offline-first behavior. - Monorepos: single
createApiinstance shared across packages viainjectEndpoints. - Auth flows:
prepareHeadersinjects JWT from Redux store on every request.
Follow-up questions
Q: How does tag invalidation work step by step?
A: A mutation with invalidatesTags: ['Post'] runs after the request resolves. RTK Query finds every query in the cache that declared providesTags: ['Post'], marks them stale, and refetches them on the next render cycle.
Q: What is the difference between query and queryFn?
A: query returns a URL string or config object that baseQuery wraps in a fetch call. queryFn gives you full control over the request, which is useful for GraphQL, WebSockets, or any non-HTTP data source.
Q: How do you handle auth token refresh inside RTK Query?
A: Wrap fetchBaseQuery in a custom baseQueryWithReauth function. Check for a 401, attempt a token refresh, update the store, and retry the original request. The Redux Toolkit docs call this pattern automatic re-authorization.
Q: What happens to cache when a component unmounts?
A: RTK Query starts a keepUnusedDataFor countdown (60 seconds by default). When it expires, the slice entry is removed. Set keepUnusedDataFor: Infinity on an endpoint to pin data permanently, or 0 to drop it immediately on unmount.
Q: (Senior) How would you implement infinite scroll with RTK Query without unbounded memory growth?
A: Use serializeQueryArgs to merge pages into a single cache key, merge to append incoming pages, and forceRefetch to detect page changes. For memory control, track loaded page numbers and evict old ones via api.util.updateQueryData or by invalidating tags for pages that scroll out of view.
Examples
Basic setup: users API with mutations
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const usersApi = createApi({
reducerPath: 'usersApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['User'],
endpoints: (builder) => ({
getUsers: builder.query({
query: () => 'users',
providesTags: ['User'],
}),
updateUser: builder.mutation({
query: ({ id, ...data }) => ({
url: `users/${id}`,
method: 'PUT',
body: data,
}),
invalidatesTags: ['User'], // triggers getUsers refetch after update
}),
}),
});
export const { useGetUsersQuery, useUpdateUserMutation } = usersApi;
function UserList() {
const { data, isLoading } = useGetUsersQuery();
const [updateUser] = useUpdateUserMutation();
if (isLoading) return <div>Loading...</div>;
return (
<ul>
{data?.map(user => (
<li key={user.id}>
{user.name}
<button onClick={() => updateUser({ id: user.id, name: 'New Name' })}>
Rename
</button>
</li>
))}
</ul>
);
}Renaming a user invalidates the User tag, so getUsers refetches automatically. No manual dispatch. No useEffect to sync state.
Production pattern: e-commerce with granular tags
export const shopApi = createApi({
reducerPath: 'shopApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Product', 'Cart'],
endpoints: (builder) => ({
getProducts: builder.query({
query: () => 'products',
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({ type: 'Product', id })),
{ type: 'Product', id: 'LIST' },
]
: [{ type: 'Product', id: 'LIST' }],
}),
addToCart: builder.mutation({
query: ({ productId }) => ({
url: 'cart',
method: 'POST',
body: { productId },
}),
invalidatesTags: ['Cart', { type: 'Product', id: 'LIST' }],
}),
}),
});Granular tags mean adding one product to a cart refetches the product list but not every individual product detail page. This cuts unnecessary network traffic in high-traffic dashboards.
Advanced: conditional fetch with polling and result transformation
function AdminPanel({ user }) {
const { activeCount, isFetching } = useGetActiveOrdersQuery(undefined, {
skip: !user?.isAdmin, // no request if not admin
pollingInterval: user?.isAdmin ? 30000 : 0, // poll every 30s when active
selectFromResult: ({ data, isFetching }) => ({
activeCount: data?.filter(o => o.status === 'active').length ?? 0,
isFetching,
}),
});
return (
<div>
Active orders: {activeCount} {isFetching && '(refreshing)'}
</div>
);
}skip: true cancels the subscription entirely, which stops polling and prevents memory leaks. selectFromResult runs after the cache lookup, so the component only re-renders when activeCount changes, not on every polling response.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.