Concept
Middleware and createAsyncThunk covered two ways to handle async logic in Redux, hand-written middleware, and RTK's generalized single-request wrapper. redux-saga is a third approach, built on JavaScript generator functions, aimed specifically at complex async workflows: coordinating multiple requests, cancellation, retries, and long-running background processes that createAsyncThunk isn't designed for.
import { call, put, takeEvery } from "redux-saga/effects";
function* fetchUserSaga(action) {
const user = yield call(api.getUser, action.payload); // effect #1
yield put({ type: "USER_LOADED", payload: user }); // effect #2
}
function* watcherSaga() {
yield takeEvery("FETCH_USER", fetchUserSaga);
}A saga is a generator function. Each yield inside it produces an effect, a plain, descriptive object saying "please do this," not a direct function call. The saga pauses at each yield until the saga middleware (running the generator) resolves that effect and resumes it with a result.
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: call() doesn't invoke anything, it describes an invocation
const gen = fetchUserSaga({ payload: 42 });
const step1 = gen.next();
console.log(step1.value);
// { "@@redux-saga/IO": true, type: "CALL", payload: { context: null, args: [42], fn: [Function: fakeApi] } }Confirmed by manually stepping a saga's generator with .next(), with no saga middleware, no store, no real network call involved at all: yield call(fakeApi, 42) produces a plain, inspectable JavaScript object describing "call fakeApi with argument 42", fakeApi itself is never actually invoked at this point. It's the saga middleware, running separately, that receives this description, genuinely calls fakeApi(42), waits for its result, and resumes the paused generator by calling gen.next(result), injecting the resolved value back in as the return value of the original yield call(...) expression.
The four core effects
function* exampleSaga(action) {
const result = yield call(api.getData, action.id); // CALL a function, wait for its return/promise
yield put({ type: "DATA_LOADED", payload: result }); // DISPATCH an action
const currentFilter = yield select((state) => state.filter); // READ current store state
const nextAction = yield take("USER_LOGGED_OUT"); // PAUSE until a specific action is dispatched
}call(fn, ...args), describes callingfnwith the given arguments; iffnreturns a Promise, the saga pauses until it resolves (or throws, propagating as an error the saga cantry/catch).put(action), describes dispatchingactionto the store; the actualstore.dispatch(action)call happens inside the middleware, not inside the generator.select(selectorFn), describes reading the current state via a selector (per Selectors & Reselect); resumes immediately with the selector's result.
Why plain descriptions matter: real functions never run during a .next() step
Confirmed directly in the earlier snippet: calling gen.next() on a saga that yields call(fakeApi, 42) returns the effect description as step1.value, inspecting it shows fakeApi referenced as data (payload.fn), not executed. This is the exact mechanism that makes sagas independently testable (covered fully in Testing Sagas): a test can assert "this saga yields a call-fakeApi-with-42 description" by comparing plain objects, with zero real network calls, zero mocking of fetch or timers, and zero running store required.
Try It
Predict the outcome before checking the solution.
function* mySaga() {
const a = yield call(add, 1, 2);
yield put({ type: "RESULT", payload: a });
}
const gen = mySaga();
const step1 = gen.next();
console.log(typeof step1.value); // is this a number, or something else?Given add isn't even defined anywhere in this snippet, does calling gen.next() throw a ReferenceError?
Solution
"object", no error is thrown.
This directly demonstrates the confirmed effect-description behavior: yield call(add, 1, 2) never actually tries to invoke add at this point, it just builds and returns a plain object describing "call whatever add refers to, with arguments 1 and 2." Since add is only ever referenced as data inside that object (not called), the fact that it's undefined never matters until something later actually tries to execute it, and nothing in this snippet does. This is a genuinely useful property: a saga can be constructed, stepped through, and its yielded descriptions inspected, entirely independent of whether the referenced functions are real, mocked, or even defined at all.
Implement It Yourself
Build a drastically simplified version of what a saga middleware does, running a generator and interpreting call/put effect objects, to see the mechanism directly:
function call(fn, ...args) {
return { type: "CALL", fn, args }; // just a plain object, no invocation
}
function put(action) {
return { type: "PUT", action }; // just a plain object, no dispatch
}
async function runSaga(generatorFn, dispatch) {
const gen = generatorFn();
let input;
while (true) {
const { value: effect, done } = gen.next(input);
if
This makes the core mechanism concrete: call()/put() are just plain object factories, all the real work (actually calling functions, actually dispatching) happens inside runSaga's loop, which is exactly the role the real saga middleware plays, just far more robustly (handling every effect type, cancellation, errors, and more).
Under the Hood
This builds directly on Middleware's established pattern of middleware being given access to dispatch and able to fire further actions, redux-saga is, at its core, one large middleware that knows how to run generator functions and interpret their yielded effects. It composes with Selectors & Reselect via select(), and connects to Store's single-dispatch-funnel model, since every put() effect ultimately becomes a real store.dispatch() call.
Common Mistakes
1. Calling the function directly instead of yielding call()
function* badSaga(action) {
const user = await api.getUser(action.payload); // ❌ direct call, bypasses the effect system entirely
yield put({ type: "USER_LOADED", payload: user });
}Bypassing call() in favor of directly await-ing (note: await inside a plain generator function isn't even valid without also being async function*, an unusual combination) removes the entire benefit of the effect-description pattern, the saga is no longer testable without actually invoking the real function, and loses the middleware's ability to intercept, cancel, or otherwise manage that specific operation.
2. Dispatching directly instead of yielding put()
function* badSaga(action, store) { // ❌ passing the store into a saga isn't the pattern at all
const user = yield call(api.getUser, action.payload);
store.dispatch({ type: "USER_LOADED", payload: user }); // ❌ bypasses put()
}Sagas never receive the store directly, dispatching happens exclusively through put(), kept as a describable, interceptable effect rather than a direct side effect buried inside the generator.
3. Forgetting that call()'s first argument can be a method needing its object context
yield call(api.getUser, id); // ❌ if getUser uses `this` internally, it loses its `api` context when called this waycall(fn, ...args) invokes fn(...args) with no bound context by default, for a method that relies on this, use call([api, api.getUser], id) or call(api.getUser.bind(api), id) to preserve the correct context.
Best Practices
- Always use
call()andput()for anything with a side effect (function calls, dispatches) inside a saga, rather than doing it directly, this keeps every side effect as an inspectable, testable description. - Prefer
select()over reaching for state through closure variables, passing state through the generator's own effect system keeps sagas consistent and testable the same way as their other effects. - Bind or array-wrap object methods passed to
call()when they rely on their ownthiscontext.
Performance Tips
- Effect descriptions (
{ type: "CALL", ... }) are lightweight plain objects, there's no meaningful overhead from usingcall/put/select/takeover their direct equivalents; the cost of a saga is the actual async work it performs, identical to any other approach. - Generator-based execution does have a small amount of inherent overhead compared to plain async/await (context-switching per
yield), negligible for typical app-level async flows, and irrelevant compared to real network latency.
