โ† All guides

Mock a REST API for RTK Query โ€” the cache that doesn't refetch, the listener you forgot, and the error shape nobody expects

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.

1. Try it in 10 seconds (no signup)

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;
โšก Skip the terminal: this link creates a live, seeded e-commerce backend (products, orders, customers, reviews) in the dashboard โ€” real URL, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.

2. StrictMode: useEffect fetches twice, the hook fetches once

React 18 StrictMode double-mounts components in dev. Measured side by side in the same StrictMode app:

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

3. Five components, one 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.

4. The error shape on a 500 โ€” error.status + error.data, and no .message

Good 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).

5. All four UI states โ€” each one reachable on demand

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}/>;
paramswhat we observed
mock_delay=2000&limit=5isLoading 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.

6. "RTK Query is not refetching" โ€” measured: that's the cache working as designed

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:

knobwhat 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 mutationsthe targeted fix โ€” see ยง7

7. invalidatesTags: the mutation that refetches your list โ€” exactly once

Tag 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).

8. refetchOnFocus does nothing until you call setupListeners

The 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:

setupfetches after window focus event
refetchOnFocus: true, no setupListeners1 โ€” 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.

9. isLoading vs isFetching, data vs currentData โ€” why your page-2 spinner never shows

Change the query arg (page 1 โ†’ page 2, with a real 800 ms mock_delay) and read the flags mid-transition. Measured:

momentisLoadingisFetchingdatacurrentData
first ever load (page 1)truetrueundefinedundefined
mid page-1โ†’2 transitionfalsetruepage-1 rows (first id 1)undefined
page 2 landedfalsefalsepage-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.

10. The stale-search race โ€” keyed cache, zero AbortControllers

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.

11. retry() vs a scripted failure sequence

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

12. pollingInterval actually polls

useGetProductsQuery('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).

13. Optimistic update with a real rollback โ€” 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.

14. Get your own API (10 seconds, no signup)

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.

15. Beyond this guide

16. Honest comparison

ToolGood atWhere this differs
TanStack QuerySame problem space without Redux; richer devtools ecosystemIf 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.
SWRTiny API, great defaults for read-heavy UIsDifferent 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 allMSW 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.
JSONPlaceholderInstant, zero-setup, famousFixed 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.