Getting started
Setup
npm install redux-saga
import createSagaMiddleware from "redux-saga";
import { configureStore } from "@reduxjs/toolkit";
import rootSaga from "./sagas";
const sagaMiddleware = createSagaMiddleware();
const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(sagaMiddleware),
});
sagaMiddleware.run(rootSaga);
Configure middleware and run root saga.
Basic Saga (Watcher + Worker)
import { call, put, takeEvery } from "redux-saga/effects";
// Worker saga
function* fetchUser(action) {
try {
const user = yield call(Api.fetchUser, action.payload.userId);
yield put({ type: "USER_FETCH_SUCCEEDED", user });
} catch (error) {
yield put({ type: "USER_FETCH_FAILED", error });
}
}
// Watcher saga
function* watchFetchUser() {
yield takeEvery("USER_FETCH_REQUESTED", fetchUser);
}
Watcher listens for actions, worker handles logic.
Root Saga
import { all, fork } from "redux-saga/effects";
export default function* rootSaga() {
yield all([
fork(watchFetchUser),
fork(watchCreateItem),
fork(watchDeleteItem),
]);
}
Combine all watchers in root saga.
Effect Creators
Blocking Effects
| Effect | Description |
|---|---|
take(pattern) |
Wait for specific action |
call(fn, ...args) |
Call function (blocks until resolved) |
apply(obj, method, args) |
Call method with context |
select(selector) |
Read store state |
join(task) |
Wait for forked task |
put(action) |
Dispatch action (usually async) |
putResolve(action) |
Dispatch action (blocks) |
import { take, call, select, put } from 'redux-saga/effects'
// Wait for action
const action = yield take('ACTION_TYPE')
const action = yield take(['TYPE_A', 'TYPE_B']) // Multiple
const action = yield take(action => action.ready) // Predicate
// Call function
const result = yield call(fetchApi, '/url', params)
const result = yield call([obj, obj.method], arg1)
// Read state
const state = yield select()
const value = yield select(state => state.counter)
// Dispatch
yield put({ type: 'ACTION', payload: data })
yield putResolve({ type: 'ACTION' }) // Blocking
Non-Blocking Effects
| Effect | Description |
|---|---|
fork(saga, ...args) |
Attached child (errors bubble up) |
spawn(saga, ...args) |
Detached child (independent) |
cancel(task) |
Cancel forked task |
cancelled() |
Check if cancelled |
delay(ms) |
Wait for milliseconds |
import { fork, spawn, cancel, cancelled, delay } from 'redux-saga/effects'
// Fork: attached child
const task = yield fork(saga, arg1, arg2)
// Spawn: detached child
const task = yield spawn(saga, arg1, arg2)
// Cancel task
yield cancel(task)
// Check if cancelled
function* saga() {
try {
// ...work...
} finally {
if (yield cancelled()) {
// cleanup logic
}
}
}
// Delay
yield delay(1000) // Wait 1 second
Watcher Helpers
| Helper | Concurrent? | Cancels? | Use Case |
|---|---|---|---|
takeEvery |
✅ Yes | ❌ No | Parallel requests |
takeLatest |
❌ No | ✅ Yes | Search (latest wins) |
takeLeading |
❌ No | ❌ No | Prevent duplicates |
throttle |
❌ No | ❌ No | Rate limiting |
debounce |
❌ No | ❌ No | Wait for silence |
import { takeEvery, takeLatest, takeLeading, throttle, debounce } from 'redux-saga/effects'
// Run on EVERY action (parallel)
yield takeEvery('ACTION', workerSaga)
// Cancel previous, run latest
yield takeLatest('SEARCH', workerSaga)
// Ignore new while running
yield takeLeading('SUBMIT', workerSaga)
// At most once per 500ms
yield throttle(500, 'INPUT', workerSaga)
// After 500ms of silence
yield debounce(500, 'INPUT', workerSaga)
Effect Combinators
race
import { race, call, delay, take } from 'redux-saga/effects'
// First to complete wins, losers cancelled
const { response, timeout } = yield race({
response: call(fetchApi, '/data'),
timeout: delay(5000),
})
if (timeout) {
console.log('Request timed out')
}
// Cancel on user action
const { data, cancel } = yield race({
data: call(longRunningTask),
cancel: take('CANCEL_TASK'),
})
⚠️ race automatically cancels all losing effects.
all (parallel execution)
import { all, call } from 'redux-saga/effects'
// Array form
const [users, posts] = yield all([
call(fetchUsers),
call(fetchPosts),
])
// Object form
const { users, posts } = yield all({
users: call(fetchUsers),
posts: call(fetchPosts),
})
⚠️ If one fails, all others are cancelled.
retry
import { retry } from 'redux-saga/effects'
// Retry 3 times with 10s delay
const data = yield retry(3, 10000, fetchApi, '/data')
Automatically retry failed operations.
Channels
actionChannel
import { actionChannel, take, call } from "redux-saga/effects";
// Buffer actions, process serially
function* watchRequests() {
const channel = yield actionChannel("REQUEST");
while (true) {
const { payload } = yield take(channel);
yield call(handleRequest, payload);
}
}
Buffer actions for serial processing.
eventChannel (external events)
import { eventChannel, END } from "redux-saga";
import { take, call } from "redux-saga/effects";
function createWebSocket(url) {
return eventChannel((emit) => {
const ws = new WebSocket(url);
ws.onmessage = (e) => emit(JSON.parse(e.data));
ws.onclose = () => emit(END); // Close channel
return () => ws.close(); // Unsubscribe
});
}
function* watchMessages() {
const channel = yield call(createWebSocket, "ws://...");
try {
while (true) {
const message = yield take(channel);
yield put({ type: "MESSAGE_RECEIVED", message });
}
} finally {
console.log("WebSocket closed");
}
}
Connect external event sources to sagas.
Generic Channel (saga-to-saga)
import { channel } from "redux-saga";
import { take, fork, put } from "redux-saga/effects";
function* watchRequests() {
const chan = yield call(channel);
// Create 3 worker threads
for (let i = 0; i < 3; i++) {
yield fork(handleRequest, chan);
}
while (true) {
const { payload } = yield take("REQUEST");
yield put(chan, payload);
}
}
function* handleRequest(chan) {
while (true) {
const payload = yield take(chan);
// process payload
}
}
Implement worker pool pattern.
Common Patterns
Polling
function* pollData() {
while (true) {
try {
const data = yield call(fetchApi, "/data");
yield put({ type: "DATA_RECEIVED", data });
} catch (error) {
yield put({ type: "DATA_FAILED", error });
}
yield delay(5000);
}
}
// Start/stop polling
function* watchPolling() {
while (yield take("START_POLLING")) {
const task = yield fork(pollData);
yield take("STOP_POLLING");
yield cancel(task);
}
}
Periodic data fetching with start/stop control.
Auth Flow
function* authFlow() {
while (true) {
const { payload } = yield take("LOGIN_REQUEST");
const task = yield fork(authorize, payload);
const action = yield take(["LOGOUT", "LOGIN_FAILURE"]);
if (action.type === "LOGOUT") {
yield cancel(task);
}
yield call(clearSession);
}
}
Handle login/logout lifecycle.
Undo Pattern
function* onArchive(action) {
yield put(actions.showUndo());
yield put(actions.archiveLocally(action.threadId));
const { undo, archive } = yield race({
undo: take("UNDO"),
archive: delay(5000),
});
yield put(actions.hideUndo());
if (undo) {
yield put(actions.unarchive(action.threadId));
} else {
yield call(api.archive, action.threadId);
}
}
Give users chance to undo actions.
Error Handling
// Per-saga try/catch
function* fetchData() {
try {
const data = yield call(api.fetch);
yield put({ type: "SUCCESS", data });
} catch (error) {
yield put({ type: "FAILURE", error: error.message });
}
}
Wrap saga logic in try/catch blocks.
Root Saga Patterns
Resilient Root Saga
// Using spawn for auto-restart
export default function* rootSaga() {
const sagas = [watchUsers, watchPosts, watchAuth];
yield all(
sagas.map((saga) =>
spawn(function* () {
while (true) {
try {
yield call(saga);
break;
} catch (e) {
console.error(`Saga ${saga.name} crashed, restarting...`, e);
}
}
}),
),
);
}
⚠️ fork bubbles errors to parent (crash stops all). spawn is detached (crash is isolated).
Testing
Step-by-Step
import { call, put } from "redux-saga/effects";
const gen = fetchUser({ payload: { userId: 1 } });
// Test each yield
expect(gen.next().value).toEqual(call(Api.fetchUser, 1));
const user = { id: 1, name: "John" };
expect(gen.next(user).value).toEqual(
put({ type: "USER_FETCH_SUCCEEDED", user }),
);
expect(gen.next().done).toBe(true);
Test generator step-by-step with assertions.
With redux-saga-test-plan
import { expectSaga } from "redux-saga-test-plan";
import * as matchers from "redux-saga-test-plan/matchers";
expectSaga(mySaga)
.provide([
[matchers.call.fn(Api.fetch), { data: "response" }],
[matchers.select(getUser), { id: 1 }],
])
.put({ type: "SUCCESS", data: "response" })
.run();
Use test-plan library for cleaner tests.
Gotchas
- Effects are declarative objects, not executed directly —
yield call(fn)notyield fn() select()reads state AFTER reducers have processed the actionrace()automatically cancels all losing effects- Errors in
fork()ed tasks bubble up to parent (usespawn()for isolation) actionChannelbuffers all messages by default — usebuffers.sliding(n)to limit- TypeScript: Add
DOM.IterableorES2015.Iterabletotsconfig.jsonlib
Also see
- Redux Saga docs (redux-saga.js.org)
- API reference (redux-saga.js.org)
- Redux cheatsheet (nexus)