Concept
takeLatest, takeEvery, debounce, retry confirmed a specific, important limitation: takeLatest's cancellation stops a saga's generator continuation, but doesn't automatically abort an already-in-flight async operation. This topic covers fork(), cancel(), and the cancelled() effect, the tools for making cancellation genuinely clean up after itself, plus race(), a distinct effect for running multiple operations and reacting to whichever finishes first.
fork(): starting a task without blocking, so it CAN be cancelled later
import { fork, cancel, cancelled, delay, call } from "redux-saga/effects";
function* mainSaga() {
const task = yield fork(backgroundWork); // starts backgroundWork WITHOUT pausing mainSaga here
yield delay(1000);
yield cancel(task); // explicitly cancel it, if it's still running
}Unlike call(), which pauses the calling saga until the called generator finishes, fork() starts a generator running concurrently, immediately returning a task object (task above) that represents that running instance, critically, this task object is exactly what cancel() needs to actually target a specific running saga for cancellation.
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.
Confirmed: cancel() triggers real cleanup via finally + cancelled()
function* backgroundWork() {
try {
yield delay(5000); // long-running operation
console.log("completed normally");
} finally {
if (yield cancelled()) {
console.log("was CANCELLED, running cleanup");
// e.g., abort a real network request, release a lock, clear a timer
}
}
}Confirmed by execution: forking backgroundWork and cancelling it (via cancel(task)) before its delay(5000) resolves correctly triggers the finally block, with yield cancelled() inside it correctly returning true, this is genuinely, mechanically different from the task simply finishing normally (where finally still runs, but cancelled() would report false). This try/finally + cancelled() pattern is exactly what's needed to make a saga's cancellation do real cleanup work, like calling abortController.abort() on a real in-flight fetch, closing a channel from , or releasing an acquired lock, closing the specific gap alone leaves open.
race(): run multiple effects, react to whichever settles first, abandon the rest
function* fetchWithTimeout(id) {
const { data, timeout } = yield race({
data: call(api.getData, id),
timeout: delay(5000),
});
if (timeout) {
throw new Error("Request timed out");
}
return data;
}Confirmed by execution: race({ fast: ..., slow: ... }), where fast genuinely resolves before slow, produces a result object with only the fast key populated (fast: "fast"), slow's key is confirmed undefined, and its underlying effect is abandoned (not awaited further, its eventual resolution, if any, simply ignored). This is precisely the mechanism a timeout pattern like the one above relies on: data and timeout race against each other, and checking which key is populated afterward tells you which one actually won.
Try It
Predict the outcome before checking the solution.
function* worker() {
try {
yield delay(1000);
yield put({ type: "DONE" });
} finally {
const wasCancelled = yield cancelled();
yield put({ type: wasCancelled ? "CLEANUP_CANCELLED" : "CLEANUP_NORMAL" });
}
}
function* mainSaga() {
const task = yield fork(worker);
yield delay(1000); // let worker run to COMPLETION, not cancelled early
yield cancel(task); // cancel AFTER it already finished normally
}