Concept
Effects covered call/put/select/take as the building blocks of a single saga. This topic covers the watcher effects that decide how many instances of a worker saga run concurrently, and how they respond to rapid, repeated dispatches of the same action type, a decision with real behavioral consequences, not just a style choice.
takeEvery: every matching action gets its own independent worker
function* worker(action) {
const result = yield call(api.fetchData, action.payload);
yield put({ type: "LOADED", payload: result });
}
function* watcherSaga() {
yield takeEvery("FETCH", worker); // starts a NEW worker for EVERY matching action, concurrently
}Confirmed by dispatching three rapid "FETCH" actions against a takeEvery watcher: all three worker instances run to completion independently and concurrently, none of them cancel each other, and all three results end up in the store. This is the right choice when every dispatched action genuinely represents independent work that should all complete (e.g., three separate items being individually saved).
function* fetchUserSaga(action) {const user = yield call(api.getUser, action.payload);yield put({ type: 'USER_LOADED', payload: user });}store.dispatch({ type: 'FETCH_USER', payload: 1 });
The saga middleware watches for matching actions, takeEvery starts a new run of fetchUserSaga each time FETCH_USER is dispatched.
takeLatest: only the most recent matters, confirmed subtlety in what "cancel" actually stops
function* watcherSaga() {
yield takeLatest("FETCH", worker); // cancels the PREVIOUS worker instance when a new matching action arrives
}Confirmed by the identical three-rapid-dispatches test against a takeLatest watcher instead: only one LOADED action ever reaches the store, from the third (most recent) dispatch. But the underlying async function passed to call() was confirmed to have actually run three separate times (its own internal call counter reached 3, not 1), takeLatest's cancellation stops the saga generator's continuation (the first two worker instances never resume past their call() to reach their put()), but it does not abort an already-in-flight Promise that isn't itself designed to be cancellable. This is a genuinely important, easy-to-miss distinction: takeLatest prevents stale results from being acted on, but doesn't necessarily prevent the underlying work from happening, a real network request already sent isn't retroactively un-sent just because its result gets discarded.
debounce: wait for a quiet period before running at all
function* watcherSaga() {
yield debounce(300, "SEARCH_INPUT_CHANGED", worker); // waits 300ms of SILENCE before running
}Confirmed by dispatching three rapid actions spaced closer together than the debounce window, each one resetting the countdown: the worker ran exactly once, using only the last dispatched action's payload, the underlying async function's call counter confirmed exactly 1 invocation, not 3. This is the right tool specifically for input-driven triggers (search-as-you-type, autosave) where intermediate values genuinely don't matter and firing on every keystroke would be wasteful, unlike takeLatest, which still runs (and pays the cost of) every dispatch's async work, just discarding all but the last result.
retry: no built-in effect, composed from call + delay in a loop
function* retrySaga(fn, maxAttempts, delayMs) {
for (let i = 0; i < maxAttempts; i++) {
try {
return yield call(fn);
} catch (e) {
if (i === maxAttempts - 1) throw e; // out of attempts, propagate the final failure
yield delay(delayMs);
}
}
}
function* mainSaga() {
const result = yield call(retrySaga, flakyApi, 5, 1000); // retries UP TO 5 times, 1s apart
yield put({ type: "SUCCESS", payload: result });
}Confirmed by running this exact pattern against a function engineered to fail twice before succeeding: the saga correctly retried, waiting between attempts via delay(), and ultimately dispatched SUCCESS with the third attempt's result, all using only the four effects already covered in Effects, composed inside an ordinary generator function passed to call() like any other. There's no special "retry effect" needed; retry logic is just a loop, expressed naturally in generator syntax.
Try It
Predict the outcome before checking the solution.
let apiCallCount = 0;
function trackedApi(payload) {
apiCallCount++;
return new Promise((resolve) => setTimeout(() => resolve(payload), 20));
}
function* worker(action) {
const result = yield call(trackedApi, action.payload);
yield put({ type: "LOADED", payload: result });
}
function* watcherSaga() {
yield takeLatest("FETCH", worker);
}
// dispatch three actions in rapid succession, all well within the 20ms delay
store.dispatch({ type: "FETCH", payload: "a" });
store.dispatch({ type: "FETCH", payload: "b" });
store.dispatch({ type: "FETCH", payload: "c" });
// after everything settles:
console.log(apiCallCount); // how many times did trackedApi actually run?
console.log(store.getState().loaded); // what ends up in the store?What does apiCallCount end up as, and what's in store.getState().loaded?
Solution
apiCallCount is 3. store.getState().loaded contains only ["c"].
This is the exact confirmed takeLatest subtlety: all three dispatches start a worker, and all three workers' call(trackedApi, ...) genuinely invoke trackedApi, the function itself has no awareness of being "cancelled," so it runs to completion for "a", "b", and "c" independently, all three incrementing apiCallCount. But takeLatest cancels the first two saga generator instances as soon as a newer matching action arrives, meaning worker #1 and worker #2 never resume past their yield call(...) line to reach their yield put(...) line, since their generators were torn down mid-pause. Only worker #3 (spawned by the last dispatch, never superseded by anything newer) survives to actually dispatch LOADED. The lesson: takeLatest guarantees only the latest result gets acted on, not that only the latest request gets made, for that stronger guarantee, the underlying async operation itself needs to support real cancellation (e.g. via AbortController, checked inside the saga using the cancelled() effect from Cancellation & race()).
Implement It Yourself
Sketch a minimal version of takeLatest, to see exactly how the cancel-on-new-dispatch mechanism works:
function myTakeLatest(actionType, workerSaga) {
return function* watcher() {
let currentTask = null;
while (true) {
const action = yield take(actionType); // wait for a matching action
if (currentTask) {
yield cancel(currentTask); // cancel the PREVIOUS worker instance, if still running
}
currentTask = yield fork(workerSaga, action); // start a NEW worker, don't block the watcher loop
}
};
}This makes the mechanism explicit: takeLatest is really just a loop, take() to wait for the next matching action, cancel() the previous forked task if one exists, fork() a new one (non-blocking, so the watcher loop can immediately go back to waiting for the next action while the worker runs in the background). cancel() and fork() are covered fully in Cancellation & race(), but this sketch shows takeLatest isn't a fundamentally different mechanism from the effects already covered, just a specific, common composition of them.
Under the Hood
This topic directly extends Effects, takeEvery/takeLatest/debounce are all built from the same take/fork/cancel primitives, just pre-composed for common concurrency patterns. The confirmed "cancellation stops the generator, not necessarily the underlying async operation" distinction connects directly to Cancellation & race(), which covers how to make an operation genuinely abortable using the cancelled() effect for cleanup logic.
Common Mistakes
1. Using takeEvery for a search-as-you-type feature
yield takeEvery("SEARCH_CHANGED", searchWorker); // ❌ fires a real request on EVERY keystroketakeEvery doesn't debounce or cancel anything, every single dispatched action spawns its own independent worker. For rapid, bursty input, this means a real network request per keystroke, all running concurrently, with results potentially arriving out of order. debounce (or takeLatest, if intermediate requests firing is acceptable but only the last result should matter) is the correct choice.
2. Assuming takeLatest prevents the earlier requests from ever being sent
// Misconception: "takeLatest means only ONE network request ever gets made"Confirmed false: takeLatest cancels the saga's ability to act on stale results, but doesn't retroactively un-send an already-in-flight request. If actually preventing redundant network calls (not just ignoring their results) matters, e.g. for cost or rate-limiting reasons, the async function itself needs real cancellation support (AbortController), checked via the cancelled() effect covered in Cancellation & race().
3. Reaching for a "retry" library or plugin when a plain loop suffices
// Overengineering: importing a dedicated retry addon for a need well-served by a 6-line generator loopAs shown above, retry logic is naturally expressed as an ordinary for loop with try/catch and yield delay(...) between attempts, composed from effects already covered in Effects, no special retry primitive or third-party addon is needed for the common case.
Best Practices
- Choose the watcher effect based on the actual desired semantics, not habit:
takeEveryfor genuinely independent concurrent work,takeLatestwhen only the most recent result matters (but earlier requests may still fire),debouncewhen intermediate triggers should be suppressed entirely (search-as-you-type). - If truly preventing redundant network calls matters (not just ignoring stale results), pair
takeLatestwith real request cancellation (AbortController+ thecancelled()effect), nottakeLatestalone. - Express retry logic as a plain loop with
call+delay, kept inside a small, separately-testable generator function, rather than reaching for external retry utilities for straightforward cases.
Performance Tips
debounce's confirmed single-invocation behavior for rapid successive triggers is a genuine performance and cost win overtakeLatestfor input-driven scenarios, it avoids firing (and paying for) every intermediate request, not just discarding their results after the fact.takeEvery's fully concurrent worker instances mean a burst of dispatched actions can spawn many simultaneous in-flight operations, for actions genuinely representing independent work this is correct and desired, but it's worth being deliberate about whether unlimited concurrency is actually safe for the specific operation (e.g. a backend rate limit might require capping concurrency, whichtakeEveryalone doesn't do).
