In Redux Toolkit, how do you stop a slow, earlier createAsyncThunk response from overwriting a newer one when a detail page fetches on every route change?
answer
- responses arrive out of order
- every call has its own id
- the returned promise can be cancelled
- pass the signal to fetch
basics
~20 sRecord meta.requestId from the pending action and ignore fulfilled or rejected actions whose requestId is not the latest, and abort the superseded call from the effect cleanup with promise.abort(), passing thunkAPI.signal to fetch so the request itself is cancelled.
solid answer
~40 sEach dispatch of a `createAsyncThunk` gets a unique `meta.requestId`, and `fulfilled` is dispatched whenever its request resolves, so an old response that lands last wins. Two fixes combine well. In the slice, store `action.meta.requestId` in the `pending` case and, in `fulfilled` and `rejected`, return early when `action.meta.requestId` is not the stored one. In the component, keep the promise that `dispatch(fetchItem(id))` returns and call `promise.abort()` in the `useEffect` cleanup; RTK then dispatches `rejected` with `meta.aborted: true` instead of `fulfilled`, and if the payload creator passed `thunkAPI.signal` to `fetch`, the HTTP request is cancelled too. The reducer should treat an aborted rejection as neither an error nor a result. A `condition` callback can additionally skip a fetch for an id that is already loaded or loading.
code
ts · 37 linesimport { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
interface Item { id: string; title: string }
export const fetchItem = createAsyncThunk(
'items/fetch',
async (id: string, { signal }) => {
const res = await fetch(`/api/items/${id}`, { signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return (await res.json()) as Item
},
)
const itemSlice = createSlice({
name: 'item',
initialState: { current: null as Item | null, status: 'idle', currentRequestId: '' },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchItem.pending, (state, action) => {
state.status = 'loading'
state.currentRequestId = action.meta.requestId
})
.addCase(fetchItem.fulfilled, (state, action) => {
if (action.meta.requestId !== state.currentRequestId) return
state.status = 'idle'
state.current = action.payload
})
.addCase(fetchItem.rejected, (state, action) => {
if (action.meta.requestId !== state.currentRequestId) return
if (action.meta.aborted) return
state.status = 'failed'
})
},
})
export default itemSlice.reducergo deeper
Recognise that two requests can finish in the wrong order and that the last fulfilled action to arrive decides what the page shows.
Explain meta.requestId and meta.arg on lifecycle actions, and how a pending-time guard lets the reducer drop stale fulfilled and rejected actions.
Combine the requestId guard with abort in the effect cleanup, pass the signal to the HTTP call, and keep aborted rejections out of the error UI, including under Strict Mode.
Recognise when per-thunk race handling repeated across many screens signals that server data needs a caching and deduplication layer rather than more reducer guards.
## Why the wrong item appears A detail route such as `/items/:id` typically dispatches `fetchItem(id)` in an effect whenever `id` changes. If the user moves from item 1 to item 2 quickly and the response for item 1 is slower, the sequence is: 1. `items/fetch/pending` for 1, then `items/fetch/pending` for 2; 2. `items/fetch/fulfilled` for 2 — the page shows item 2; 3. `items/fetch/fulfilled` for 1 — the reducer writes item 1 over it. `createAsyncThunk` does not cancel or ignore anything on its own: every call runs to completion and dispatches its own final action. A reducer that writes `state.current = action.payload` in `fulfilled` therefore lets whichever response arrives **last** win. This is a race between requests, not a Redux bug. ## Fix 1: ignore stale results with requestId Every lifecycle action carries `meta.requestId`, a unique id for that call, and `meta.arg`, the argument it was called with. Record the latest request and only accept results from it. - In `pending`: `state.currentRequestId = action.meta.requestId; state.status = 'loading'`. - In `fulfilled` and `rejected`: `if (action.meta.requestId !== state.currentRequestId) return` before writing anything. This fix lives entirely in the reducer, so it also protects against callers you do not control, such as a second component dispatching the same thunk. Its weakness is that the stale request still runs and still costs bandwidth. ## Fix 2: abort the superseded call The promise returned by dispatching the thunk has an `abort(reason?)` method. The natural place to call it is the effect cleanup, which React runs before the effect re-runs for the next `id` and when the component unmounts: ```tsx useEffect(() => { const promise = dispatch(fetchItem(id)) return () => promise.abort() }, [dispatch, id]) ``` What `abort()` does, in order: 1. The thunk's internal `AbortController` fires, so `thunkAPI.signal.aborted` becomes `true`. 2. RTK stops waiting for the payload creator and dispatches `items/fetch/rejected` with `meta.aborted: true` and an error whose `name` is `'AbortError'`. 3. The payload creator is not forcibly stopped. Its later result is discarded, but its work continues **unless it passed `signal` on** — to `fetch(url, { signal })` or an HTTP client's cancel option — in which case the request itself is cancelled. Because aborting produces a `rejected` action, the reducer must not show "failed to load" for it. Check `action.meta.aborted` and leave the state alone. In development with React Strict Mode, effects run, clean up and run again on mount, so you will see a first request aborted immediately. That is the cleanup doing its job, not a bug. ## Fix 3: skip requests that are not needed `createAsyncThunk` accepts a `condition(arg, { getState, extra })` option that runs before `pending`. Returning `false` cancels the call, and by default **no** action is dispatched. Use it to avoid re-fetching an item already in state or already being fetched. It prevents duplicate work but does not by itself resolve the out-of-order race. `condition` may also return a promise, which RTK awaits before deciding. With `dispatchConditionRejection: true`, a skipped call dispatches `rejected` with `meta.condition: true`, so skips become visible in the DevTools log. ## Testing the guard The `requestId` guard is plain reducer logic, so it can be unit-tested without a network. The lifecycle action creators build the actions directly: `fetchItem.pending('req-2', '2')` followed by `fetchItem.fulfilled(item1, 'req-1', '1')` should leave `state.current` untouched, while `fetchItem.fulfilled(item2, 'req-2', '2')` should write it. A second test feeds a `rejected` action whose `meta.aborted` is `true` and asserts that `status` does not become `'failed'`. ## Choosing and combining | Technique | Stops the stale write | Saves the network request | Where it lives | |---|---|---|---| | `requestId` guard | yes | no | reducer | | `promise.abort()` + `signal` | yes | yes, if `signal` reaches the HTTP call | component effect + payload creator | | `condition` | only by preventing the call | yes | thunk options | In production code the robust answer is **both** the guard and the abort: the guard guarantees correctness regardless of who dispatched, and the abort frees the network. Beyond one screen, if many components fetch server data this way, the same race, deduplication and caching logic keeps being re-implemented — at that point a dedicated data-fetching layer is worth considering, which is a separate topic. ## What to say in the interview - name the cause: out-of-order responses with last-writer-wins reducers; - show the `meta.requestId` guard; - show `promise.abort()` in the cleanup and `signal` passed to `fetch`; - mention that aborts arrive as `rejected` with `meta.aborted` and must not be rendered as errors.
- After promise.abort() in Redux Toolkit, does the payload creator's fetch stop?Only if the payload creator passed `thunkAPI.signal` to `fetch` or its HTTP client. `abort()` makes RTK dispatch the `rejected` action immediately and discard any later result, but it cannot stop code that ignores the signal, so the request would still complete in the background.
- Why does a Redux Toolkit detail page show a brief error state after navigating, once abort is added?Aborting dispatches `rejected` with `meta.aborted: true`, and a reducer that sets `status = 'failed'` for every rejection renders it as an error. Check `action.meta.aborted` in the `rejected` case and skip the update, or rely on the `requestId` guard if the aborted request is no longer the current one.
saying these in an interview costs you the question
- createAsyncThunk automatically ignores responses from superseded calls
- promise.abort() kills the fetch even without passing the signal
- An aborted thunk dispatches nothing at all
- condition returning false fixes out-of-order responses
- Checking status === 'loading' in fulfilled prevents the stale write