Your team's Redux app routes all async logic through redux-saga. How would you decide whether to keep it or move to thunks and RTK's listener middleware?
answer
- inventory sagas by job
- imperative vs reactive logic
- what the style guide recommends
- migrate incrementally, chain coexists
basics
~20 sClassify the sagas by job: fetching, imperative flows, reactive or long-running workflows. Move fetching to a data layer, imperative flows to thunks and reactive rules to the listener middleware; keep sagas only where their concurrency features earn their cost.
solid answer
~40 sStart from what the sagas actually do rather than from taste. The Redux style guide recommends a dedicated data-fetching layer for server data, thunks for imperative logic that needs `dispatch` and `getState`, and RTK's listener middleware for reactive logic that responds to actions or state changes. It recommends against sagas in most cases unless nothing simpler is powerful enough. The listener middleware covers common saga patterns: `cancelActiveListeners()` gives takeLatest behaviour, and `take`, `condition`, `delay` and `fork` support longer workflows. Sagas still earn their place for heavy channel use, complex fork and cancellation trees, or a large test suite built on effect descriptions. Because all three are ordinary middleware, they can share one chain, so migrate module by module as code is touched, measuring onboarding time and bug rates, not with a rewrite.
go deeper
Know the three options by name, thunks, sagas and the listener middleware, and that thunks are the simplest and are installed by default in Redux Toolkit.
Explain the difference between imperative logic (thunks) and reactive logic (listeners, sagas), and what each tool looks like in code.
Map concrete features to the right tool, such as fetching, submit flows, reactive rules and websockets, and justify each with the concurrency it needs.
Own the migration decision: inventory, team cost, test assets, coexistence in one chain, a rule for new code, and explicit criteria for keeping sagas.
## Frame the decision This is a judgement call with no universal answer. The question is not "are sagas bad?" but **"does the complexity we pay for buy capabilities we use?"** A lead is expected to answer it with evidence from the codebase, a view of the team, and a migration path that does not stop feature work. ## Step 1: inventory what the sagas do Classify every saga by its job. In most codebases they fall into four groups: | Group | Typical saga | Candidate replacement | |---|---|---| | Server data fetching and caching | `takeLatest(FETCH_X)` → `call(api)` → `put(success)` | A dedicated data-fetching layer | | Imperative flows | Submit, then save, then navigate | A thunk | | Reactive rules | "When A happens, also do B" | Listener middleware `startListening` | | Long-running orchestration | Polling, websockets via channels, complex races | Listener middleware, or keep the saga | The ratio decides a lot. If most sagas are fetch-and-put, the team is paying for a concurrency toolkit to do request lifecycles. ## Step 2: know what each tool gives you - **Thunks**: plain functions with `dispatch`, `getState` and an extra argument. Minimal concepts, easy to type, but they run only when someone dispatches them; they do not react to other actions. - **Listener middleware** (`createListenerMiddleware` in Redux Toolkit): register effects with `startListening({ actionCreator | type | matcher | predicate, effect })`. Effects are ordinary async functions that receive the action and a `listenerApi` offering `getState`, `getOriginalState`, `dispatch`, `cancelActiveListeners`, `take`, `condition`, `delay`, `fork` and an `AbortSignal`. Toolkit's docs describe it as a lightweight alternative to sagas and observables. It can express `takeLatest`, `takeLeading`, `debounce` and fork/join patterns, but it does not claim to replace every saga feature. Effects run after the reducers have processed the action. - **Sagas**: generators yielding effect descriptions; a rich operator set (`fork`, `race`, `all`, channels, `actionChannel`); structured cancellation that propagates through forked trees; tests that compare yielded effects without mocks. The costs are a steep learning curve, awkward TypeScript typing of `yield` results, and extra bundle weight. ## Step 3: weigh the team, not just the tools 1. **Onboarding.** How long do new engineers take to change a saga safely? Generators plus the effect model are often the slowest part of a Redux codebase to learn. 2. **Types.** Does the team fight `yield` typing, with values typed `any` flowing through sagas? 3. **Incidents.** Are bugs clustered in saga concurrency, such as missing cancellation or races, or elsewhere? 4. **Tests.** A large, valuable effect-based test suite is a real asset; migrating it has a cost. 5. **Direction of the ecosystem.** Redux's official guidance steers new code towards Toolkit, thunks, listeners and a data-fetching layer; hiring and documentation follow that. ## Step 4: decide per group, then migrate incrementally - **Default outcome**: move fetching to the data layer, imperative flows to thunks, and simple reactive rules to listeners; **keep sagas** for the few orchestration-heavy features where channels and cancellation trees are doing real work. - **Coexistence is fine.** Thunk, listener and saga middleware are all ordinary middleware in one chain; Toolkit's docs prepend the listener middleware ahead of the defaults. Nothing forces a big-bang rewrite. - **Migrate on touch.** Convert a saga when its feature is next changed, with tests at the behaviour level (actions in, state out) so they survive the rewrite. - **Set a rule for new code** so the old pattern stops spreading while the migration runs. - **Define success**: fewer lines per feature, faster onboarding, fewer concurrency bugs, and eventually removing the saga dependency, or consciously deciding to keep it. ## Worked example: converting one saga A typical autocomplete saga uses `takeLatest('search/queryChanged', worker)`, where the worker calls the API and `put`s the results. The listener version: 1. `startListening({ type: 'search/queryChanged', effect })`. 2. In `effect(action, listenerApi)`, call `listenerApi.cancelActiveListeners()` first, which cancels any earlier run still in progress. 3. Optionally `await listenerApi.delay(300)` to debounce; a cancelled run throws out of `delay` and stops there. 4. Fetch with `listenerApi.signal` passed to `fetch`, so cancellation also aborts the request. 5. `listenerApi.dispatch` the results. The code is ordinary `async`/`await`, fully typed, and needs no generator knowledge. That is the everyday argument for migrating. ## When keeping sagas is the right call - The product is genuinely orchestration-heavy: realtime collaboration, device protocols, multi-step workflows with timeouts and races. - The team is fluent and incidents are low. - The migration would consume time better spent on the product. A principal-level answer names these conditions explicitly instead of treating either tool as dogma.
- How do you get takeLatest behaviour with RTK's listener middleware?Call listenerApi.cancelActiveListeners() at the start of the effect. It cancels other running instances of the same listener, and those instances observe the cancellation through their abort signal or when awaiting listener API methods such as delay or take, which then throw. Combined with a short await listenerApi.delay(ms), it also gives debounce-like behaviour.
- Can thunks, the listener middleware and redux-saga run in the same store during a migration?Yes. Each is ordinary Redux middleware, so they compose in one applyMiddleware or configureStore chain. Order matters only in the usual way; for example Toolkit's docs prepend the listener middleware. That coexistence is what makes an incremental, feature-by-feature migration practical.
saying these in an interview costs you the question
- Sagas and thunks cannot be installed in the same store
- The listener middleware is a drop-in replacement for every saga feature
- Migration means rewriting every saga in one release
- Thunks can react to actions dispatched elsewhere without extra middleware
- The Redux style guide recommends redux-saga as the default for async logic