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