A Redux + redux-saga Wiring I Still Reach For
redux-saga still earns its keep when async workflows get tangled. The wiring I copy-paste into legacy codebases, plus the takeEvery vs takeLatest decision and the canonical fetch saga.
By @felixhaddad
May 11, 2026
·
Updated May 20, 2026
1,138 views
21
Rate
// The bootstrap. createSagaMiddleware returns the middleware AND a .run
// method you call after applyMiddleware so the root saga is registered
// against the live store. Important: .run must come AFTER createStore;
// calling it before is the most common bootstrap bug.
// Tiny stand-ins so the snippet runs in the playground.
function applyMiddleware() {
const mws = Array.prototype.slice.call(arguments);
return function enhancer(createStoreFn) {
return function (reducer, initialState) {
const store = createStoreFn(reducer, initialState);
const chain = mws.map((mw) => mw({ getState: store.getState, dispatch: (a) => store.dispatch(a) }));
const composedDispatch = chain.reduceRight(
(next, mw) => mw(next),
store.dispatch
);
return Object.assign({}, store, { dispatch: composedDispatch });
};
};
}
function createStoreBase(reducer, initialState) {
let state = initialState;
const listeners = [];
return {
getState: () => state,
dispatch: (action) => { state = reducer(state, action); listeners.forEach((l) => l()); return action; },
subscribe: (l) => { listeners.push(l); },
};
}
function createStore(reducer, initialState, enhancer) {
return enhancer ? enhancer(createStoreBase)(reducer, initialState) : createStoreBase(reducer, initialState);
}
// createSagaMiddleware stand-in: collects dispatched actions and routes them
// to whatever generator the user .run's. The real one wires up the channel,
// effect runtime, and error boundary; the wiring shape is the same.
function createSagaMiddleware() {
let runningSaga = null;
let storeApi = null;
const middleware = function (api) {
storeApi = api;
return function (next) {
return function (action) {
const result = next(action);
if (runningSaga && typeof runningSaga.next === 'function') runningSaga.next(action);
return result;
};
};
};
middleware.run = function (saga) {
runningSaga = saga(storeApi);
if (runningSaga && typeof runningSaga.next === 'function') runningSaga.next();
};
middleware.feed = function (action) {
if (runningSaga && typeof runningSaga.next === 'function') runningSaga.next(action);
};
return middleware;
}
// Other middlewares we usually compose with sagas: a logger, a thunk shim.
const logger = ({ getState }) => (next) => (action) => {
if (typeof console !== 'undefined') console.log('[mw]', action.type);
return next(action);
};
const thunk = ({ dispatch, getState }) => (next) => (action) =>
typeof action === 'function' ? action(dispatch, getState) : next(action);
// The saga we will register.
function* rootSaga(api) {
let action;
while (true) {
action = yield;
if (action && action.type === 'PING') api.dispatch({ type: 'PONG' });
}
}
function reducer(state, action) {
state = state || { lastSeen: null };
return Object.assign({}, state, { lastSeen: action.type });
}
const sagaMiddleware = createSagaMiddleware();
const store = createStore(reducer, { lastSeen: null }, applyMiddleware(logger, thunk, sagaMiddleware));
sagaMiddleware.run(rootSaga); // <-- after createStore, never before
store.dispatch({ type: 'PING' });
console.log('after PING, lastSeen:', store.getState().lastSeen);
store.dispatch((dispatch) => dispatch({ type: 'THUNK_FIRED' }));
console.log('after thunk, lastSeen:', store.getState().lastSeen);The order in the bootstrap is the load-bearing detail. applyMiddleware(logger, thunk, sagaMiddleware) is the conventional stack: logger sees every action first, thunk handles function-typed actions, sagaMiddleware feeds the saga runtime, and the reducer is at the end of the chain. sagaMiddleware.run(rootSaga) has to come after createStore because the run call needs the live dispatch and getState references that the middleware captured during applyMiddleware. Putting it before, or forgetting it entirely, is the bug that produces the silent failure mode where every dispatched action is logged but no saga ever fires. Mixing thunks and sagas in one app is fine; thunks for fire-and-forget side effects, sagas for anything that needs cancellation, debouncing, or coordinated multi-step workflows.
// The two effects you reach for first when wiring async work. takeEvery runs
// the worker for every matching action; takeLatest cancels the previous in-
// flight worker before starting a new one. The right pick is decided by
// whether the user can fire the action faster than the work completes.
// Stand-ins for the saga effect helpers. Real ones live in redux-saga/effects.
const CALL = 'CALL', PUT = 'PUT', TAKE_EVERY = 'TAKE_EVERY', TAKE_LATEST = 'TAKE_LATEST';
function call(fn) {
const args = Array.prototype.slice.call(arguments, 1);
return { type: CALL, fn, args };
}
function put(action) { return { type: PUT, action }; }
function takeEvery(actionType, worker) { return { type: TAKE_EVERY, actionType, worker }; }
function takeLatest(actionType, worker) { return { type: TAKE_LATEST, actionType, worker }; }
// Tiny saga runtime: feeds actions into watchers, runs workers, supports
// cancellation for takeLatest by tracking the current worker iterator.
function runSaga(rootGen, store) {
const watchers = [];
const it = rootGen();
let step = it.next();
while (!step.done) {
watchers.push(step.value);
step = it.next();
}
const workers = new Map(); // actionType -> { worker, mode, current }
for (const w of watchers) workers.set(w.actionType, { worker: w.worker, mode: w.type, current: null });
return {
dispatch(action) {
const entry = workers.get(action.type);
if (!entry) return;
// takeLatest: cancel any in-flight worker for this action type.
if (entry.mode === TAKE_LATEST && entry.current) {
if (entry.current.return) entry.current.return();
entry.current = null;
}
const w = entry.worker(action);
entry.current = w;
const stepWorker = (input) => {
let next = w.next(input);
while (!next.done) {
const eff = next.value;
if (eff && eff.type === CALL) {
Promise.resolve(eff.fn.apply(null, eff.args)).then((result) => stepWorker(result));
return;
}
if (eff && eff.type === PUT) store.dispatch(eff.action);
next = w.next(eff);
}
if (entry.current === w) entry.current = null;
};
stepWorker();
},
};
}
// Reducer counts each completed result so we can see what landed.
let completed = [];
function reducer(state, action) {
state = state || {};
if (action.type === 'SEARCH_DONE') completed.push(action.payload);
return state;
}
const store = { dispatch: (action) => reducer({}, action) };
// API stub. Returns a promise that resolves after a delay we control.
function fakeApiSearch(query, delayMs) {
return new Promise((resolve) => setTimeout(() => resolve('result for ' + query), delayMs));
}
// Worker: fetch and put SEARCH_DONE.
function* searchWorker(action) {
const result = yield call(fakeApiSearch, action.payload, action.delay || 0);
yield put({ type: 'SEARCH_DONE', payload: result });
}
// takeEvery: every keystroke runs to completion. Fast typing -> stale results
// can land out of order.
function* watchEvery() {
yield takeEvery('SEARCH', searchWorker);
}
const saga1 = runSaga(watchEvery, store);
completed = [];
saga1.dispatch({ type: 'SEARCH', payload: 'a', delay: 30 });
saga1.dispatch({ type: 'SEARCH', payload: 'ab', delay: 10 });
saga1.dispatch({ type: 'SEARCH', payload: 'abc', delay: 5 });
setTimeout(() => {
console.log('takeEvery completed (order may not match dispatch):', completed);
}, 60);
// takeLatest: the previous in-flight worker is cancelled when a new
// SEARCH arrives. Only the last query completes.
function* watchLatest() {
yield takeLatest('SEARCH', searchWorker);
}
const latestStore = { dispatch: (action) => reducer({}, action) };
let latestCompleted = [];
function latestReducer(state, action) {
if (action.type === 'SEARCH_DONE') latestCompleted.push(action.payload);
return state;
}
latestStore.dispatch = (action) => latestReducer({}, action);
const saga2 = runSaga(watchLatest, latestStore);
saga2.dispatch({ type: 'SEARCH', payload: 'a', delay: 30 });
saga2.dispatch({ type: 'SEARCH', payload: 'ab', delay: 10 });
saga2.dispatch({ type: 'SEARCH', payload: 'abc', delay: 5 });
setTimeout(() => {
console.log('takeLatest completed (last wins):', latestCompleted);
}, 60);If the action is something the user fires repeatedly faster than the work completes (search-as-you-type, autosave, validate-on-keystroke), takeLatest is the right default. Each new dispatch cancels the previous in-flight worker, so the only result that lands is the latest one, which avoids the stale-response bug where a slow request for 'a' overwrites a fast result for 'abc'. takeEvery is the right default for actions where each instance is independent and you want all of them to run to completion: dispatching analytics events, recording log lines, processing items from a queue. Mixing them in one watcher is also fine: yield takeEvery('LOG_EVENT', logWorker) plus yield takeLatest('SEARCH', searchWorker) covers the typical pair of cadences in a single saga file.
// Anatomy of a real fetch saga. takeEvery on the request action, the worker
// uses call() so the test runner can stub the API, put() the success or
// error action, and try/catch around the call so a network failure cleanly
// becomes a FETCH_USER_ERROR. This is the shape I copy-paste at least once
// per legacy migration.
const CALL = 'CALL', PUT = 'PUT', TAKE_EVERY = 'TAKE_EVERY';
function call(fn) {
const args = Array.prototype.slice.call(arguments, 1);
return { type: CALL, fn, args };
}
function put(action) { return { type: PUT, action }; }
function takeEvery(actionType, worker) { return { type: TAKE_EVERY, actionType, worker }; }
function runSaga(rootGen, store) {
const watchers = [];
const it = rootGen();
let step = it.next();
while (!step.done) { watchers.push(step.value); step = it.next(); }
const workers = new Map();
for (const w of watchers) workers.set(w.actionType, { worker: w.worker });
return {
dispatch(action) {
const entry = workers.get(action.type);
if (!entry) return;
const w = entry.worker(action);
const stepWorker = (input, isError) => {
let next;
try {
next = isError ? w.throw(input) : w.next(input);
} catch (err) {
if (typeof console !== 'undefined') console.log('uncaught saga error:', err.message);
return;
}
while (!next.done) {
const eff = next.value;
if (eff && eff.type === CALL) {
Promise.resolve()
.then(() => eff.fn.apply(null, eff.args))
.then((result) => stepWorker(result, false))
.catch((err) => stepWorker(err, true));
return;
}
if (eff && eff.type === PUT) store.dispatch(eff.action);
next = w.next(eff);
}
};
stepWorker();
},
};
}
// API stubs. The happy path returns a user; the failure path throws.
const api = {
fetchUser: (id) => id === 99
? Promise.reject(new Error('user 99 not found'))
: Promise.resolve({ id, name: 'User-' + id }),
};
function* fetchUserSaga(action) {
try {
const user = yield call(api.fetchUser, action.payload.id);
yield put({ type: 'FETCH_USER_SUCCESS', payload: user });
} catch (err) {
yield put({ type: 'FETCH_USER_ERROR', payload: { id: action.payload.id, message: err.message } });
}
}
function* watchFetchUser() {
yield takeEvery('FETCH_USER', fetchUserSaga);
}
// Reducer that records every action so we can verify the right one fired.
const log = [];
const store = { dispatch: (action) => { log.push(action.type + ':' + JSON.stringify(action.payload || {})); } };
const saga = runSaga(watchFetchUser, store);
saga.dispatch({ type: 'FETCH_USER', payload: { id: 1 } });
saga.dispatch({ type: 'FETCH_USER', payload: { id: 99 } });
setTimeout(() => {
console.log('saga produced these actions in order:');
log.forEach((line) => console.log(' -', line));
console.log('happy path -> FETCH_USER_SUCCESS, error path -> FETCH_USER_ERROR with message');
}, 30);Three habits make these sagas testable and resilient. First, yield call(api.fetchUser, id) instead of calling the API directly: the saga yields a description of the call, and the test runner asserts on that descriptor without ever hitting the network. Second, the try/catch around the call so any rejected promise from the API turns into a FETCH_USER_ERROR action with structured payload; the reducer can render an error banner and the rest of the app keeps moving. Third, put for both success and error so the entire side-effect surface is just "the saga dispatches actions in response to actions", which is the part that pays back when you debug a complex flow six months later. Add select for reading store slices, delay for retries, and cancelled for cleanup, and you have the saga vocabulary that covers most real workflows.
