A search box built with redux-saga's takeEvery sometimes shows results for an older query. Why, and how do takeLatest and task cancellation fix it?
answer
- one forked task per action
- responses resolve out of order
- cancel the previous task
- cancelling a saga does not abort fetch
basics
~20 stakeEvery forks a worker per keystroke, so requests overlap and whichever response arrives last dispatches its results, even for an older query; takeLatest cancels the previous worker before forking a new one, so only the newest query dispatches.
solid answer
~40 sWith `takeEvery('search/queryChanged', searchWorker)`, each action forks an independent task that runs `yield call(fetchResults, query)` and then `yield put(resultsReceived)`. Responses do not arrive in request order, so a slow response for "re" can land after the fast one for "redux" and overwrite it. `takeLatest` forks a worker per action but first cancels the previous one if it is still running. The cancelled generator jumps to its `finally` block, where `yield cancelled()` is true, and never reaches its `put`. One catch: cancelling the saga does not abort the HTTP request unless the promise carries a `[CANCEL]` method, for example one that calls `AbortController.abort()`. So add that if wasted requests matter, or use `debounce` to send fewer of them.
code
ts · 29 linesimport { CANCEL, type SagaIterator } from 'redux-saga'
import { call, cancelled, put, takeLatest } from 'redux-saga/effects'
type QueryChanged = { type: 'search/queryChanged'; payload: string }
function fetchResults(query: string): Promise<string[]> {
const controller = new AbortController()
const promise = fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
}).then(res => res.json())
// redux-saga calls this when the task waiting on the promise is cancelled
;(promise as unknown as Record<string, () => void>)[CANCEL] = () => controller.abort()
return promise
}
function* searchWorker(action: QueryChanged): SagaIterator {
try {
const results: string[] = yield call(fetchResults, action.payload)
yield put({ type: 'search/resultsReceived', payload: results })
} finally {
if (yield cancelled()) {
yield put({ type: 'search/superseded', payload: action.payload })
}
}
}
export function* searchSaga(): SagaIterator {
yield takeLatest('search/queryChanged', searchWorker)
}go deeper
Know that sagas are generators that yield effects like call and put, and that takeEvery runs a worker for every action while takeLatest keeps only the newest.
Explain effects as descriptions the middleware executes, the concurrency difference between takeEvery, takeLatest and takeLeading, and cancellation via finally and cancelled().
Diagnose the out-of-order race, choose between takeLatest, debounce and takeLeading per feature, and handle the fact that cancellation does not abort promises on its own.
Weigh saga-based orchestration against simpler tools for the team, considering learning curve, testability and how much concurrency control the product really needs.
## redux-saga in one paragraph redux-saga is a Redux middleware that runs **sagas**, generator functions that describe side effects. A saga does not call APIs or dispatch directly. It **yields effect descriptions**, plain objects created by helpers such as `call`, `put` and `take` from `redux-saga/effects`, and the middleware executes them. You create the middleware with `createSagaMiddleware()` (the default export of `redux-saga`), install it with `applyMiddleware`, then start your root saga with `sagaMiddleware.run(rootSaga)`. Running a saga before the middleware is mounted throws in development. The middleware passes each action to the reducers first and then feeds it to the sagas, so a saga reacting to an action already sees the updated state. ## The core effects used here | Effect | What it tells the middleware | |---|---| | `call(fn, ...args)` | Call `fn`; if it returns a promise, suspend the saga until it settles | | `put(action)` | Dispatch `action` to the store | | `takeEvery(pattern, worker)` | Fork `worker` for **every** matching action, concurrently | | `takeLatest(pattern, worker)` | Fork `worker` per action, **cancelling** the previous one if still running | | `takeLeading(pattern, worker)` | Run `worker` and **ignore** matching actions until it finishes | | `debounce(ms, pattern, worker)` | Run `worker` only after `ms` without a new matching action | | `cancelled()` | Inside `finally`, report whether this task was cancelled | ## Why `takeEvery` shows stale results Typing "r", "re", "redux" dispatches three `search/queryChanged` actions. With `takeEvery`: 1. Three worker tasks run **concurrently**, one per query. 2. Each yields `call(fetchResults, query)`, so three requests are in flight. 3. Network latency varies: the "redux" response might arrive first and "re" last. 4. Each worker `put`s its results when its own response arrives, so the **last response wins**, and the screen shows results for "re" while the box says "redux". Nothing is wrong with each worker in isolation; the bug is **uncoordinated concurrency**. ## How `takeLatest` fixes it `takeLatest` is built from `take`, `fork` and `cancel`: on each matching action it cancels the task it forked last time, if that task is still running, and forks a new one. For the search box: 1. The "r" worker is running when "re" arrives, so it is **cancelled**. 2. The "re" worker is cancelled when "redux" arrives. 3. Only the "redux" worker survives to its `put`. Cancellation works by making the generator **return**: it jumps straight into its `finally` block. There, `yield cancelled()` is `true`, which is the place to dispatch a "search cancelled" action or clean up. Cancellation also propagates down to tasks the cancelled task forked, and to the effect it was blocked on. ## The HTTP request keeps running Cancelling a saga task stops the **generator**; it does not magically stop the **promise** it was waiting on. For a `call` that returned a promise, redux-saga looks for a function on the promise under the `CANCEL` key exported by `redux-saga`, and calls it on cancellation. Without it, the request completes in the background and its result is simply ignored. If wasted requests matter, for example on a rate-limited API, attach `promise[CANCEL] = () => controller.abort()` using an `AbortController`. ## Could you fix it without cancellation? Yes, and weighing it is a good senior signal. Two alternatives: - **Tag and drop.** Put the query into the results action and have the reducer ignore results whose query no longer matches the current one. It works with `takeEvery` and needs no cancellation, but every stale request still completes and still costs a reducer call. - **Sequence numbers.** Store a request counter and ignore responses carrying an older number. This is more robust when the same query can repeat. Both keep correctness in the reducer, which is easy to test. `takeLatest` keeps it in the saga layer and stops stale work earlier. Many teams combine them: `takeLatest` for efficiency, plus a query check in the reducer as a safety net. ## Choosing the helper - **Search-as-you-type**: `takeLatest`, often combined with `debounce` to avoid a request per keystroke. - **"Submit" buttons that must not double-fire**: `takeLeading`. - **Independent events that should all be handled**, such as analytics pings: `takeEvery`. ## Testing benefit Because `call(fetchResults, 'redux')` is just a description, a test can step the generator with `next()` and compare the yielded value with `call(fetchResults, 'redux')` using deep equality, without mocking `fetch` at all. That declarative, mock-free testing style is one of the main reasons teams chose sagas.
- What does takeLeading do differently, and when is it the right choice?takeLeading runs the worker for the first matching action and ignores further matching actions until that worker finishes, instead of cancelling it. It suits actions where the first request should win, such as a payment or form submission that must not be sent twice.
- Does a saga see an action before or after the reducers have processed it?After. The saga middleware calls next(action) first, so the reducers run, and then puts the action into the saga channel. A saga that reads state with select after taking an action therefore sees the state that action produced.
saying these in an interview costs you the question
- takeEvery handles actions one at a time, so responses cannot overlap
- takeLatest ignores new actions while a worker is running
- Cancelling a saga task automatically aborts its fetch request
- yield call(api, id) runs the API in a separate thread
- Sagas see actions before the reducers run