Skip to main content

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 createSlice instead).
  • 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

javascript
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 ready

One 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

javascript
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.

javascript
// 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.

javascript
// 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.

javascript
// 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.

javascript
// 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: 30000 for live order counts without WebSockets.
  • React Native: RTK Query cache persisted with redux-persist for offline-first behavior.
  • Monorepos: single createApi instance shared across packages via injectEndpoints.
  • Auth flows: prepareHeaders injects 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

javascript
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

javascript
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

javascript
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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?