All questions

Redux-Saga Effect Runner

Premium

Redux-Saga Effect Runner

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.

Signature

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.

Examples

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' } ]

Notes

  • Effects are plain data — every creator just returns a tagged object and performs no work; only the runner performs effects. That is what lets you test a saga by stepping the generator and asserting the yielded effect equals the expected creator output.
  • CALL — invoke 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 and TAKE share one channelput 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 blocks, FORK does notcall 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.
  • ALL runs concurrently, delay waitsall(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.
  • Out of scope — cancellation, takeEvery / takeLatest, and a scheduler. The six effect creators plus the runner are the whole job.

FAQ

Why do sagas yield effect objects instead of doing the work directly?
Because a yielded effect is plain data, the runner performs the side effect and the saga only describes it. That lets you unit-test an entire async flow by stepping the generator and asserting on the effects it yields, with no mocks, network, or timers.
What is the difference between call and fork?
call runs a function and blocks the saga until its result is ready, awaiting a returned promise before resuming. fork starts a saga concurrently and resumes the parent immediately with a task handle, so the child runs alongside without blocking.
How does a rejected effect reach a saga's try/catch?
The runner resumes the generator with gen.throw(error) instead of gen.next(value), which raises the error at the paused yield. A try/catch around that yield then handles it exactly like synchronous code.

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.

Upgrade to Premium