Redux Toolkit answers: What is the current application state?
Redux Saga answers: What should happen next when an action occurs?
Redux Toolkit simplifies state management, while Redux Saga orchestrates complex asynchronous workflows such as task queues, cancellation, timeouts, and concurrent execution.
Let’s explore these concepts through a realistic example: a Concurrent Test Runner.

Imagine an application that runs automated tests. Multiple users can submit test runs, but the system allows only three tests to execute simultaneously.
Running:#101#102#103Queue:#104#105
When test #101 finishes, the scheduler starts #104.
What if a user cancels #103? Or test #102 exceeds its timeout?
We need a system that can coordinate workers, enforce concurrency limits, handle failures, and keep Redux state synchronized.
That’s where Redux Saga becomes useful.
Redux Saga communicates with Redux through effects such as select and put.
select reads the current Redux state:
const queue = yield select(selectQueue);const runningIds = yield select(selectRunningIds);const maxConcurrency = yield select(selectMaxConcurrency);
put dispatches an action:
yield put(runStarted(runId));yield put(runCompleted(runId));
Use select to make scheduling decisions and put to report state changes.
Redux Saga provides several effects for managing asynchronous work.
| Effect | Purpose |
|---|---|
call | Execute a function and wait for its result |
fork | Start a Saga task without blocking the caller |
join | Wait for a task to finish |
cancel | Cancel a running Saga task |
race | Run competing effects and keep the first result |
For example:
const task = yield fork(runTest, runId);
The returned task represents the running Saga. The scheduler can track it and cancel it later.
To wait for multiple workers:
const tasks = [];for (const user of users) {tasks.push(yield fork(runUser, user));}for (const task of tasks) {yield join(task);}
Each worker starts independently, while join lets the parent wait for completion.
Dispatching a Redux action that marks a test as cancelled does not, by itself, stop its Saga task.
Use cancel(task) to request cancellation:
function* runTest(runId: string) {try {yield call(executeTests, runId);yield put(runCompleted(runId));} finally {if (yield cancelled()) {yield put(runCancelled(runId));}}}
When the Saga is cancelled, its finally block executes, allowing cleanup and state synchronization.
Important: Cancelling a Saga does not automatically terminate arbitrary external work. The underlying operation must support cancellation or cleanup if it needs to stop too.
raceSuppose a test must finish within 30 seconds.
const result = yield race({test: call(executeTests, runId),timeout: delay(30_000),});
The first effect to finish determines the result.
result.test contains the test result if execution finishes first.result.timeout is set if the delay finishes first.The losing Saga effect is cancelled. However, the underlying test process may need an explicit cancellation mechanism.
You can also race against a cancellation action:
const result = yield race({test: call(executeTests, runId),timeout: delay(30_000),cancel: take(action => cancelRun.match(action)&& action.payload === runId),});
Now the worker can react to test completion, timeout, or user cancellation. The caller must inspect the winning key and update the run status accordingly.
takeEvery vs. takeLatestThese helpers determine how Saga responds to repeated actions.
takeEvery
yield takeEvery(runRequested.type, handleRunRequested);
Starts a new worker for every matching action. This is useful when each submitted test run must be handled independently.
However, takeEvery does not enforce a concurrency limit by itself. The scheduler must manage the queue and active worker count.
takeLatest
yield takeLatest(searchRequested.type, searchSaga);
Cancels the previous Saga task when a newer matching action arrives.
For example:
search("rea") → cancelledsearch("reac") → cancelledsearch("react") → latest task continues
This works well for search requests, where older results are no longer needed.
Redux Toolkit remains the recommended standard approach for Redux state management.
createSlice defines reducers and generates action creators.configureStore configures the store and common middleware.createAsyncThunk handles straightforward asynchronous operations.For example, a slice can store the test runner’s state:
type TestRun = {id: string;status: "queued" | "running" | "completed" | "failed" | "cancelled" | "timeout";};const testRunsSlice = createSlice({name: "testRuns",initialState: { runs: [] as TestRun[] },reducers: {runStarted(state, action: PayloadAction<string>) {const run = state.runs.find(item => item.id === action.payload);if (run) run.status = "running";},runCompleted(state, action: PayloadAction<string>) {const run = state.runs.find(item => item.id === action.payload);if (run) run.status = "completed";},},});
Saga coordinates execution and dispatches actions; Redux Toolkit reducers update the state that the UI reads.
Redux Toolkit and Redux Saga solve complementary problems:
fork and join coordinate background tasks.cancel and finally support cancellation and cleanup.race handles competing outcomes such as completion, timeout, and cancellation.takeEvery and takeLatest control how repeated actions spawn or cancel workers.Use Redux Saga when workflow complexity justifies it. For simpler asynchronous requests, createAsyncThunk or RTK Query is often a better fit.