RTK Query is the data-fetching layer built into Redux Toolkit, and its top search queries are all variations of one confusion: "rtk query not refetching", "refetchOnFocus not working", "error.message is undefined". Almost none of these are bugs โ they're cache policy, a missing one-liner, and an error shape that differs from axios. This guide pairs RTK Query with a hosted mock API whose failures you can script, and every number below was measured in a real Chrome on RTK Query 2.12.0 + React 18.3.1 (Vite, StrictMode on) with an instrumented window.fetch before publishing. Nothing here is folklore.
A shared, self-resetting demo project is live right now:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"
That URL is the API for every snippet below. CORS is open (Access-Control-Allow-Origin: *), so it works from localhost:5173, StackBlitz, or anywhere else. The whole API surface used in this guide:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: 'https://mockbird.mockbird.workers.dev/m/demo/' }),
tagTypes: ['Products'],
endpoints: b => ({
getProducts: b.query({ query: p => 'products?' + p, providesTags: ['Products'] }),
addProduct: b.mutation({
query: body => ({ url: 'products', method: 'POST', body }),
invalidatesTags: ['Products'],
}),
}),
});
export const { useGetProductsQuery, useAddProductMutation } = api;
useEffect fetches twice, the hook fetches onceReact 18 StrictMode double-mounts components in dev. Measured side by side in the same StrictMode app:
| pattern | fetches observed (dev, StrictMode) |
|---|---|
useEffect(() => { fetch(โฆ) }, []) | 2 โ both mounts fetch |
useGetProductsQuery('limit=5') | 1 โ the second mount subscribes to the in-flight cache entry |
Measured: exactly 1 request, list rendered. RTK Query keys the cache by endpoint + serialized argument; the double mount is just a second subscription to the same entry, not a second request.
Same mechanism, bigger payoff โ call the hook wherever the data is needed instead of lifting state:
function Badge() {
const { data } = useGetProductsQuery('limit=3');
return <span>{data?.length ?? 'โฆ'}</span>;
}
// render five of them, side by side
Measured: 5 mounted components, 1 network request, all five rendered "3". Identical args โ identical cache key โ one fetch, five subscribers.
error.status + error.data, and no .messageGood news first: unlike a raw fetch (or SWR with a non-throwing fetcher), fetchBaseQuery treats any non-2xx response as an error automatically โ you cannot accidentally render an error body as data. But the error object is not what axios habits expect:
const { data, error, isError } = useGetProductsQuery('limit=5&mock_status=500');
Measured with a forced 500: isError was true, data stayed undefined, and the error object was:
error.status // 500 (number, or "FETCH_ERROR" for network failures)
error.data // { "error": "simulated 500 error (mock_status)" } โ the parsed response body
error.message // undefined โ there is no .message on FetchBaseQueryError
If your error UI renders error.message, it renders blank. Render error.status and something from error.data instead โ and because ?mock_status=<code> forces any status on any endpoint, you can render every error branch on purpose. Also measured: exactly 1 fetch โ RTK Query does not retry by default (see ยง10 to add it).
const { data, error, isLoading } = useGetProductsQuery(params);
if (isLoading) return <Skeleton/>;
if (error) return <ErrorBox status={error.status}/>;
if (data.length === 0) return <EmptyState/>;
return <List items={data}/>;
| params | what we observed |
|---|---|
mock_delay=2000&limit=5 | isLoading true during the real 2-second delay ("loading" on screen), then "data: 5" |
mock_status=500 | "error: 500" branch rendered |
category=nonexistent-cat | [] โ "empty" (the state everyone forgets to design) |
More recipes: testing loading & error states.
The #1 confusion. Mount a list, navigate away (unmount), come back. SWR would revalidate; RTK Query serves the cache entry and does not touch the network:
Measured: mount โ 1 fetch โ unmount โ remount = still 1 fetch total. The remounted list showed "4 items" instantly with zero requests โ an unused cache entry lives for keepUnusedDataFor (default 60 seconds) and re-subscribing within that window is a pure cache hit, with no background revalidation. Your data isn't failing to refetch; it's cached on purpose. Three measured ways to change the policy:
| knob | what we measured |
|---|---|
keepUnusedDataFor: 1 (per-endpoint or api-wide) | unmount, wait 2s, remount โ 2 fetches โ the entry expired, so remount refetched |
useGetProductsQuery(arg, { refetchOnMountOrArgChange: true }) | instant remount โ 2 fetches โ always refetch on subscribe (SWR-style freshness) |
invalidatesTags on your mutations | the targeted fix โ see ยง7 |
invalidatesTags: the mutation that refetches your list โ exactly onceTag invalidation is RTK Query's flagship feature: the mutation declares what it stales, subscribed queries refetch automatically. We measured it against a list that starts empty (writes persist on this API, so the refetched list really contains the new row):
getProducts: b.query({ query: p => 'products?' + p, providesTags: ['Products'] }),
addProduct: b.mutation({
query: body => ({ url: 'products', method: 'POST', body }),
invalidatesTags: ['Products'], // โ stales every getProducts subscription
}),
Measured: 1 initial GET (0 rows) โ one addProduct() POST โ exactly 1 automatic GET โ list rendered 1 row, no manual refetch() anywhere. If your invalidatesTags "isn't working", the usual culprits are a tag string that doesn't match providesTags, or the mutation erroring before success (invalidation only fires on success โ pair it with ?mock_status=500 and watch no refetch happen).
refetchOnFocus does nothing until you call setupListenersThe classic silent no-op. refetchOnFocus: true is a policy; the events come from setupListeners, and nothing warns you if you forgot it:
createApi({ ..., refetchOnFocus: true }) // policy: refetch when window regains focus
// the line everyone forgets, in store setup:
import { setupListeners } from '@reduxjs/toolkit/query';
setupListeners(store.dispatch); // โ wires focus/online events to the cache
Measured, same app, same focus event, only difference is the one line:
| setup | fetches after window focus event |
|---|---|
refetchOnFocus: true, no setupListeners | 1 โ the focus event is ignored; only the mount fetch ever happened |
refetchOnFocus: true + setupListeners(store.dispatch) | 2 โ focus triggered a refetch |
Same story for refetchOnReconnect. If tab-switching never refreshes your data, grep your store setup for setupListeners first.
isLoading vs isFetching, data vs currentData โ why your page-2 spinner never showsChange the query arg (page 1 โ page 2, with a real 800 ms mock_delay) and read the flags mid-transition. Measured:
| moment | isLoading | isFetching | data | currentData |
|---|---|---|---|---|
| first ever load (page 1) | true | true | undefined | undefined |
| mid page-1โ2 transition | false | true | page-1 rows (first id 1) | undefined |
| page 2 landed | false | false | page-2 rows (first id 11) | page-2 rows |
So: isLoading is only true for the first load of an endpoint โ gate your page-transition spinner on isFetching. And data deliberately holds the previous page while the next one loads (built-in keep-previous-data), while currentData is undefined until the new arg's response lands โ use currentData when showing stale rows would be wrong. Pagination pairs nicely with the X-Total-Count header this API exposes via CORS.
Type fast and a slow early response lands after a fast later one. We forced it: q=life with a real 3-second mock_delay, then switched the arg 300 ms later to q=way with no delay:
const { data } = useGetProductsQuery(`q=${q}`); // the query IS the cache key
Measured: UI showed way: 7 within a second โ and still way: 7 after the slow life response landed 3 seconds later. The stale response was filed under its own cache key; the hook only reads the key it's currently subscribed to, so the race can't clobber the UI. Two fetches, zero AbortControllers, zero cleanup code โ the same win we measured for SWR and TanStack Query, because keyed caches make this bug structurally impossible.
retry() vs a scripted failure sequenceRTK Query ships a retry wrapper, off by default. ?mock_seq= serves a deterministic status sequence per key โ "fails twice, then succeeds" becomes reproducible instead of hoped-for:
import { retry, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
baseQuery: retry(fetchBaseQuery({ baseUrl: BASE }), { maxRetries: 5 })
useGetProductsQuery('limit=3&mock_seq=503,503,200&mock_seq_key=run-42');
Measured: exactly 3 fetches โ 503, 503, 200 โ recovered in ~2.1 s. The observed gaps (752 ms, then 1299 ms) show the built-in exponential backoff with jitter. Use a fresh mock_seq_key per run so the sequence restarts. Chaos and jitter variants (?mock_chaos=, ?mock_jitter=) are in the docs.
pollingInterval actually pollsuseGetProductsQuery('limit=1', { pollingInterval: 500 });
Measured: 6 fetches in 3.2 seconds โ the rate you asked for. Worth stating because it's not a given: SWR's refreshInterval: 500 is silently throttled to 2 fetches in the same window by its default dedupingInterval (we measured that in the SWR guide). RTK Query has no such trap. Writes persist here, so POST a row mid-poll and watch the next tick pick it up. Related knob: skipPollingIfUnfocused (which, like ยง8, needs setupListeners to know about focus).
patchResult.undo()Optimistic UI is easy to demo when the write succeeds. The part worth testing is the rollback โ so we forced the POST to fail with a real 500 (which this API treats as "server died before processing": the write is not applied, matching what a real outage does):
addOptimistic: b.mutation({
query: body => ({ url: 'products?mock_status=500', method: 'POST', body }),
async onQueryStarted(body, { dispatch, queryFulfilled }) {
const patch = dispatch(api.util.updateQueryData('getProducts', 'limit=3',
draft => { draft.push({ id: 'tmp', ...body }); })); // draft is Immer โ mutate away
try { await queryFulfilled }
catch { patch.undo(); } // โ the rollback
},
}),
Measured: the list rendered 3 rows โ 4 rows (optimistic item visible) โ back to 3 rows when the 500 landed. And verified server-side: the failed write really wasn't applied (a lookup for the optimistic row returned []), so a later refetch agrees with the rolled-back UI โ no ghost row. Drop the mock_status param and the same code persists the row for real. Note the arg passed to updateQueryData must exactly match the query's arg โ mismatched args are the silent-no-op version of this feature.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H "Content-Type: application/json" \
-d '{"preset": "ecommerce"}'
The response contains your project URL and admin key. Presets: blog, ecommerce, saas, payments โ or define your own resources and field types, import an OpenAPI spec / db.json / CSV / Postman collection / HAR, and swap baseUrl in the createApi above. Ship day is an env-var change: VITE_API_URL=https://api.yourcompany.com.
There's a mock JWT login too โ any email/password gets a real signed token from /auth/login, and /auth/me reads it back. Build the whole auth slice against it before an auth backend exists โ guide.
POST/PATCH/DELETE actually stick (unlike JSONPlaceholder's write theater) โ so invalidatesTags flows and optimistic rollbacks can be verified against reality.GET /m/<project>/types.ts generates interfaces from your live schema (?format=zod for Zod schemas) โ type your endpoints for free.beforeEach โ deterministic Playwright/Cypress runs (guide).?mock_ratelimit=5 gives you real 429s with Retry-After โ see how retry() behaves under throttling./m/<project>/graphql if part of your app speaks GraphQL.| Tool | Good at | Where this differs |
|---|---|---|
| TanStack Query | Same problem space without Redux; richer devtools ecosystem | If you're not already on Redux, it's usually the lighter choice โ we have a TanStack Query guide too. If your app state lives in Redux, RTK Query's integration (one store, createSlice interop, tags) is the draw. The mock API side is identical. |
| SWR | Tiny API, great defaults for read-heavy UIs | Different freshness philosophy โ SWR revalidates on mount by default, RTK Query serves cache (ยง6). Mutations and invalidation are hand-rolled in SWR, declarative here. Our SWR guide measures the same scenarios. |
| MSW (Mock Service Worker) | In-process request interception for unit tests; no network at all | MSW mocks live inside each test runner. Mockbird is a real hosted URL โ the same mock serves your browser, CI, a StackBlitz repro, and a teammate's machine with zero setup. Use MSW for unit tests, a hosted mock for everything shared. |
| JSONPlaceholder | Instant, zero-setup, famous | Fixed dataset, writes are faked โ invalidatesTags refetching after a POST just shows you the same list, and optimistic rollback can't be tested against write theater. Fine for a first tutorial; not for testing real flows. |
Bias disclosure: this comparison is written by the Mockbird side. The measurements above, though, are just measurements โ rerun them in your own Chrome with the snippets as written.