A saga is a generator that describes async work as data: instead of calling side effects itself, it yields plain objects like call(fetchUser, id) or put(action), and a runner — the interpreter — performs each one and resumes the generator with the result. This is the core idea behind redux-saga: because the yielded effects are inert data, an entire async flow can be unit-tested by stepping the generator and asserting on what it yields, with no mocks. You will build that runner and the effect creators it understands.
Generators, briefly, since the design leans on them: calling saga() returns an iterator that runs no code until gen.next(). Each yield x hands x out and pauses; gen.next(v) resumes with v as the value of that paused yield; gen.throw(e) resumes by raising e at the paused yield, so a try/catch around it can catch it. The runner drives exactly that loop. Export one object, reduxSagaEffectRunner, holding the runner plus six effect creators.
const reduxSagaEffectRunner: {
// effect creators — each returns a PLAIN, tagged object; no work is done here
call(fn, ...args); // -> { type: 'CALL', fn, args } invoke fn(...args), await a promise
put(action); // -> { type: 'PUT', action } dispatch the action
take(pattern); // -> { type: 'TAKE', pattern } wait for a matching action
fork(saga, ...args); // -> { type: 'FORK', saga, args } start a child saga, do not block
all(effects); // -> { type: 'ALL', effects } run effects concurrently
delay(ms); // a CALL effect that resolves after ms
// the interpreter — drive a saga generator to completion
runSaga(saga, options); // -> Task: a promise resolving to the saga's return value
};
options carries the environment: dispatch (where put sends actions) and getState. The returned task is a promise, and it also exposes task.dispatch(action) so outside code can feed actions into the runner for take.
Effects are data, so a saga is testable with no runner and no mocks — just step it:
const { call, put } = reduxSagaEffectRunner;
function* loadUser(id) {
const user = yield call(fetchUser, id);
yield put({ type: 'SET_USER', user });
return user;
}
const gen = loadUser(7);
gen.next().value; // { type: 'CALL', fn: fetchUser, args: [7] } === call(fetchUser, 7)
const user = { id: 7 };
gen.next(user).value; // { type: 'PUT', action: { type: 'SET_USER', user } }
gen.next().done; // true — the saga returned `user`
Run a saga through the interpreter, and wait on an action:
const { runSaga, take, put } = reduxSagaEffectRunner;
function* onApprove() {
const action = yield take('APPROVE'); // pauses here...
yield put({ type: 'DONE', by: action.by }); // ...resumes when APPROVE arrives
}
const dispatched = [];
const task = runSaga(onApprove, { dispatch: (a) => dispatched.push(a) });
task.dispatch({ type: 'APPROVE', by: 'Ada' }); // feed an action into the runner
await task;
dispatched; // [ { type: 'DONE', by: 'Ada' } ]
fn(...args). If it returns a promise, await it and resume with the resolved value; a rejection or a synchronous throw is thrown back into the saga with gen.throw, so a try/catch around the yield handles it.put dispatches through options.dispatch; take pauses the saga until a matching action is dispatched into the runner, by a put or by task.dispatch. A pattern is a string (matches action.type), '*' (any action), or a predicate function.call waits for its result before the saga continues; fork starts the child saga concurrently and resumes the parent immediately with a task handle running alongside it.delay waits — all(effects) runs every effect at once and resumes with all results once the last settles, rejecting on the first failure. delay(ms) resolves after ms via a real setTimeout — use real timers when testing, never fake ones.takeEvery / takeLatest, and a scheduler. The six effect creators plus the runner are the whole job.Unlock the solution & editor
Runnable editor + tests
Solve in the browser with instant Jest feedback.
Detailed solutions
Walkthroughs, edge cases, and complexity notes.
Multi-framework variants
React, Vue, Vanilla, Angular — same question, different stacks.