NexusCS

Redux Saga

React
Redux Saga is a middleware for managing side effects in Redux using generator functions. This guide covers redux-saga v1.x.

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) not yield fn()
  • select() reads state AFTER reducers have processed the action
  • race() automatically cancels all losing effects
  • Errors in fork()ed tasks bubble up to parent (use spawn() for isolation)
  • actionChannel buffers all messages by default — use buffers.sliding(n) to limit
  • TypeScript: Add DOM.Iterable or ES2015.Iterable to tsconfig.json lib

Also see