โ† All guides

Redux Toolkit createAsyncThunk, measured โ€” the "saved โœ“" that lied, the reducer nobody wrote, and the 303-fetch storm

createAsyncThunk is how most Redux apps still fetch. RTK Query exists, but the thunk + extraReducers pattern is what a decade of codebases actually run โ€” and it has a personality: everything is an action, including your failures. That buys you a paper trail (every pending/fulfilled/rejected is inspectable) and two of the strangest gotchas in this series: a dispatch promise that resolves when the request failed, and a success action that fires into silence because the reducer for it was never written.

Everything here was reproduced and measured in a real Chrome on @reduxjs/toolkit 2.12.0 + react-redux 9.3.0 + React 19.3 (Vite, client-side) with an instrumented window.fetch and render counters before publishing. The numbers โ€” a UI that said "saved โœ“" over a store that said failed, 303 fetches in 5 seconds, a page-2 header on page-1 rows โ€” are observed counts, not estimates.

1. Try it in 10 seconds (no signup)

Every scenario below runs against Mockbird's public demo API โ€” a real hosted backend with json-server-style params and failure-simulation flags:

curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"
curl "https://mockbird.mockbird.workers.dev/m/demo/products?_page=2&_limit=5&mock_delay=1200"
curl -i "https://mockbird.mockbird.workers.dev/m/demo/products?mock_status=500"

Pagination, sorting, per-request delays, forced status codes, deterministic status sequences (mock_seq=503,503,200), and empty-state snapshots โ€” everything a thunk's failure paths need. Create your own in one curl.

2. The good path, for reference

const loadProducts = createAsyncThunk('products/load', async () => {
  const r = await fetch(API + '/products')
  return await r.json()
})

const slice = createSlice({
  name: 'products',
  initialState: { items: [], status: 'idle' },
  reducers: {},
  extraReducers: (b) => {
    b.addCase(loadProducts.pending,   (s) => { s.status = 'loading' })
    b.addCase(loadProducts.fulfilled, (s, a) => { s.status = 'ok'; s.items = a.payload })
    b.addCase(loadProducts.rejected,  (s, a) => { s.status = 'failed'; s.error = a.error?.message })
  }
})

// component
useEffect(() => { dispatch(loadProducts()) }, [dispatch])

Measured: exactly 1 fetch, 20 rows, 3 renders (mount โ†’ pending โ†’ fulfilled), clean console. This is the shape every scenario below breaks one piece of.

3. The "saved โœ“" that lied: dispatch() never rejects

The thunk threw on a 500. Watch the component:

dispatch(saveThing())
  .then(() => setUi('saved โœ“'))        // ran โ€” on a FAILED request
  .catch(() => setUi('save failed'))   // never ran

Measured: the request returned 500, the thunk threw, the store transitioned to failed โ€” and the UI showed "saved โœ“". The promise returned by dispatch(asyncThunk()) always resolves, with the fulfilled or rejected action object. Errors are payloads here, not rejections โ€” by design, so the paper trail never throws. Your .catch is dead code and nothing will tell you.

The fix is one word:

dispatch(saveThing())
  .unwrap()                                        // re-throws rejections
  .then(() => setUi('saved โœ“'))
  .catch((e) => setUi('save failed: ' + e.message))  // "save failed: HTTP 500" โ€” measured

Drill both branches against a real wire before shipping: ?mock_status=500 makes the failed branch reproducible in one query param.

4. The reducer nobody wrote: extraReducers forgotten, zero warnings

Create the thunk, dispatch the thunk, forget extraReducers (or typo the slice so the cases never attach):

Measured: the fetch happens (1 request on the wire), the fulfilled action is dispatched โ€” and nothing changes. UI stuck at idle with 0 rows, store still { items: [], status: 'idle' }, exactly 1 render, zero console output. The action fired into a store with nobody listening.

This is Redux's version of the silent freeze (MobX's is a forgotten observer()) โ€” with one honest difference: open Redux DevTools and the orphaned products/load/fulfilled action is sitting right there in the log with its full payload. The console says nothing, but the paper trail has the receipt. If your UI is frozen and the action log shows fulfilled actions marching past, you wrote the thunk and skipped the reducer.

5. StrictMode fetches twice; the condition option fetches once

React 18+ StrictMode double-invokes effects in dev. Measured: the baseline in <StrictMode> fires 2 fetches. The thunk-native fix is condition:

const loadProducts = createAsyncThunk('products/load', payloadCreator, {
  condition: (_, { getState }) => getState().products.status === 'idle'
})

Measured: exactly 1 fetch, 20 rows, store ends ok. The mechanics are worth knowing: the pending reducer sets status = 'loading' synchronously during the first dispatch, so the second dispatch's condition already sees loading and bails โ€” and a condition-cancelled thunk dispatches no rejected action by default, so your state machine never notices. This also dedupes any other double-mount for free.

6. dispatch in the render body: 303 fetches in 5 seconds

function Storm(){
  const { items } = useSelector(s => s.data)
  dispatch(loadProducts())        // in the render body โ€” no useEffect
  return <div>{items.length} items</div>
}

Every dispatch eventually lands a fulfilled that replaces items โ†’ new state โ†’ re-render โ†’ new dispatch. Measured: 102โ€“117 fetches in the first 2 seconds, 270โ€“303 by 5 seconds, renders tracking fetches 1:1 โ€” the biggest storm numbers in this series (Jotai peaked at 114, MobX at 111, VueUse at 110). React does fire one warning โ€” Cannot update a component ("Storm") while rendering a different component โ€” because the dispatch updates the store mid-render. One warning, then an unbounded loop that runs your API bill forever. The demo API answers in ~15 ms locally, which is exactly the problem: the faster your backend, the faster the storm spins. Effects (or condition) are not optional.

7. The race is in your reducer: page-2 header, page-1 rows, zero aborts

b.addCase(loadPage.pending,   (s, a) => { s.page = a.meta.arg })
b.addCase(loadPage.fulfilled, (s, a) => { s.items = a.payload })  // whoever lands LAST wins

Click page 1 (slow: mock_delay=800), then page 2 (fast: mock_delay=100) 150 ms later. Page 2 lands first, page 1 lands last and overwrites it. Measured: header "page 2", rows starting at id 1 โ€” page-1 data under a page-2 header, 0 aborts, clean console. Thunks don't cancel anything for you; every in-flight response will eventually run your fulfilled reducer.

Reproduce it deliberately โ€” two tabs, two delays:

curl "https://mockbird.mockbird.workers.dev/m/demo/products?_page=1&_limit=3&mock_delay=800"
curl "https://mockbird.mockbird.workers.dev/m/demo/products?_page=2&_limit=5&mock_delay=100"

8. Two fixes: the requestId guard vs thunkAPI.signal

Fix A โ€” discard stale (the documented pattern). Every thunk dispatch mints a meta.requestId. Track the latest; ignore the rest:

b.addCase(loadPage.pending, (s, a) => {
  s.page = a.meta.arg
  s.currentRequestId = a.meta.requestId
})
b.addCase(loadPage.fulfilled, (s, a) => {
  if (a.meta.requestId !== s.currentRequestId) return   // stale โ€” drop it
  s.items = a.payload
})

Measured: header "page 2", rows starting at id 6 โ€” correct โ€” with 0 aborts. The stale response still downloads; you just refuse to apply it.

Fix B โ€” actually cancel the network. createAsyncThunk hands your payload creator an AbortSignal; forward it to fetch, and keep the dispatch handle so you can abort the previous one:

const loadPage = createAsyncThunk('pager/load', async (p, { signal }) => {
  const r = await fetch(url(p), { signal })
  return await r.json()
})

let prev
function go(p){ prev?.abort(); prev = dispatch(loadPage(p)) }

Measured: exactly 1 abort on the wire, correct page-2 rows, and the aborted thunk lands as a rejected action with meta.aborted: true โ€” still on the paper trail. Use A when responses are cheap, B when they're not.

9. The 500 that becomes "items.map is not a function"

const r = await fetch(API + '/products')   // no r.ok check
return await r.json()                       // 500 body parses fine โ€” it's an object

A 500 with a JSON body doesn't reject fetch and doesn't fail r.json() โ€” so the error object becomes your fulfilled payload, lands in items, and the component throws. Measured: Uncaught TypeError: items.map is not a function, the React root left childless (blank page), plus React's "Consider adding an error boundary" advisory. With an error boundary the user at least sees a message instead of white. Reproduce with one param: ?mock_status=500.

10. What rejected actually carries: the body your server sent is gone

So you add the ok check and throw. Better โ€” but look at what arrives:

if (!r.ok) throw new Error('HTTP ' + r.status)
// ...
b.addCase(load.rejected, (s, a) => {
  a.error     // SerializedError: { message: 'HTTP 500', name, stack } โ€” that's all
  a.payload   // undefined
})

Measured: a.error.message === "HTTP 500", a.payload === undefined. RTK serializes thrown errors down to message/name/stack โ€” the JSON error body your API carefully sent (code, field errors, retry-after) was discarded in the thunk when you threw a bare Error.

11. rejectWithValue keeps the body

const load = createAsyncThunk('products/load', async (_, { rejectWithValue }) => {
  const r = await fetch(API + '/products')
  if (!r.ok) return rejectWithValue(await r.json())   // ship the real body
  return await r.json()
})
// rejected reducer: a.payload is the server's JSON, verbatim

Measured: the UI renders the server's actual error string โ€” simulated 500 error (mock_status) โ€” straight from a.payload.error. This is the difference between "HTTP 500" and an actionable message. Drill every branch of your error matrix with mock_status=401, 404, 422, 500 before your real backend learns to fail.

12. The middleware that yells: a Date in the store

b.addCase(load.fulfilled, (s, a) => { s.items = a.payload; s.fetchedAt = new Date() })

Alone in this series, Redux ships a tripwire for state hygiene. Measured, verbatim on first fulfilled:

A non-serializable value was detected in the state, in the path: `data.fetchedAt`.
Take a look at the reducer(s) handling this action type: nonserial/load/fulfilled.

โ€” with a link to the Redux FAQ. The app keeps working (it's a dev-mode warning, and the rows render fine), but time-travel and persistence won't survive that Date. Store Date.now() instead. Credit where due: MobX, Zustand and Jotai all let you do this silently.

13. Two components, same thunk: two fetches

Two sibling components each dispatch(loadProducts()) in their effect. Measured: 2 fetches for the same data on the same store. Thunks have no request dedupe, no cache โ€” that's the deal you take when you skip RTK Query. The condition guard collapses this to 1; without it, N mounts = N requests.

14. Retry logic you can assert on: exactly 3 fetches

A retry loop lives naturally inside the payload creator. Assert on it with a deterministic failure sequence โ€” mock_seq returns 503, 503, then 200, in order, per key:

const load = createAsyncThunk('products/load', async () => {
  for (let attempt = 1; attempt <= 5; attempt++) {
    const r = await fetch(API + '/products?mock_seq=503,503,200&mock_seq_key=test-1')
    if (r.ok) return await r.json()
    await new Promise(res => setTimeout(res, 200 * attempt))
  }
  throw new Error('gave up')
})

Measured: exactly 3 fetches, then fulfilled with 3 rows โ€” a deterministic assertion, not a flaky sleep. One thunk dispatch, one pending, one fulfilled; the retries never touch the store.

15. The empty state you never see in dev

Seeded stores mean your "No products yet" branch ships untested. Pin a request to an empty snapshot โ€” live data untouched:

curl "https://mockbird.mockbird.workers.dev/m/demo/products?mock_snapshot=empty"    # โ†’ []

Measured: the empty-state div renders (initialize items: null to distinguish "loading" from "loaded zero", or your spinner and your empty state collapse into one branch).

16. Your own backend in one curl

curl -X POST "https://mockbird.mockbird.workers.dev/api/projects" \
  -H "content-type: application/json" \
  -d '{"preset": "ecommerce"}'

That returns a live base URL with seeded products, orders, customers, reviews โ€” full CRUD, filters (?price_gte=100), pagination with X-Total-Count, relations (?_expand=customer), GraphQL, OpenAPI + TypeScript/Zod exports, and every simulation flag used above (mock_status, mock_delay, mock_seq, envelope reshaping, chaos). Or skip the terminal: one-click create / import your own spec or data. Free, no signup required, eject anytime.

17. Honest notes

Written by the Mockbird maker โ€” bias disclosed. createAsyncThunk comes out of these measurements as the most auditable fetcher in the series: every success, failure and abort is an action you can replay in DevTools, and it's the only library here that yells about non-serializable state (ยง12). The price is that everything is manual โ€” dedupe (ยง13), cancellation (ยง8), error-body plumbing (ยง11) โ€” and two defaults genuinely surprise people: the dispatch promise that resolves on failure (ยง3, fix: .unwrap()) and the rejected action that discards your server's error body (ยง10, fix: rejectWithValue). If you want those problems owned for you, that's RTK Query, TanStack Query useQuery, measured โ€” same store, same DevTools. Measurements taken Sep 24, 2026 on @reduxjs/toolkit 2.12.0, react-redux 9.3.0, React 19.3, Vite 5, client-side rendering, default middleware. Related: mocking for React, Zustand, measured, Jotai async atoms, measured, MobX, measured, React 19 use(), measured.

โ† All guides