DEV Community

Cover image for Redux Thunk vs Saga: Choosing the Best Middleware
Sahil Khurana
Sahil Khurana

Posted on • Originally published at innostax.com

Redux Thunk vs Saga: Choosing the Best Middleware

Here's the situation that gets everyone eventually.

Your Redux setup is clean. Actions dispatch, reducers update state, components re-render, the whole pipeline works exactly like it's supposed to. Then a requirement lands that needs an API call, and suddenly you're staring at a reducer wondering where exactly you're supposed to put a fetch().

You search around. Two names come up constantly: Redux Thunk and Redux Saga. Both solve async side effects in Redux. That's roughly where the similarity ends.

I've shipped production apps with both. They're not interchangeable and the difference isn't just syntax, it's a fundamentally different way of thinking about async control flow. Picking the wrong one for your project's complexity level will cost you later, usually at the worst possible time.

Quick context on what Redux actually does

For anyone newer to this: Redux centralizes your application state in a single store. Components don't manage their own data — they read from the store and dispatch actions to change it. Every state change flows through the same path: action dispatched → reducer processes it → store updates → components re-render.

The connect function from react-redux wires this into your components. It's elegant when it works, and for synchronous state changes it works really well.

The issue is that reducers have to be pure functions. No side effects, no async operations, nothing with unpredictable timing. A fetch call inside a reducer isn't just bad practice — it fundamentally breaks the model. Redux has no built-in answer for this. Thunk and Saga are both third-party answers to that gap, just wildly different ones.

Redux Thunk: Functions returning functions

Thunk's insight is simple. Normally an action creator returns a plain object. Thunk middleware intercepts action creators that return functions instead, and calls them with dispatch and getState. That's the whole trick.

javascript
const fetchData = () => {
  return (dispatch, getState) => {
    api.fetchData()
      .then(data => {
        dispatch({ type: 'FETCH_SUCCESS', payload: data });
      })
      .catch(error => {
        dispatch({ type: 'FETCH_ERROR', payload: error });
      });
  };
};
Enter fullscreen mode Exit fullscreen mode

Why does this work so well for simple cases? Because it's just JavaScript. Functions returning functions — closures, promises, .then() chains. Nothing here requires learning a new paradigm. A developer who's never touched Redux middleware before can read a thunk file and understand what it's doing in about ten minutes.

That's genuinely valuable, and I don't want to undersell it. Fast onboarding matters.

What Thunk doesn't give you is structure for when things get complicated. Need to cancel a request if the user navigates away? Manual. Race condition where two requests fire and you only want the last one? Manual. Multiple async operations that need to coordinate? You're building that coordination logic yourself, in the thunk, and it gets messy fast. I've seen thunk files that are 200 lines of nested promise chains that technically work but that nobody wants to touch.

Redux Saga: A completely different mental model

Saga takes an approach that seemed strange to me when I first encountered it. Instead of intercepting action creators, Saga runs persistent background processes — "sagas" — that watch for specific actions and respond with their own async logic. They live entirely separately from your normal Redux flow.

javascript
function* fetchDataSaga() {
  try {
    const data = yield call(api.fetchData);
    yield put({ type: 'FETCH_SUCCESS', payload: data });
  } catch (error) {
    yield put({ type: 'FETCH_ERROR', payload: error });
  }
}

function* watchFetchData() {
  yield takeEvery('FETCH_REQUEST', fetchDataSaga);
}
Enter fullscreen mode Exit fullscreen mode

The function* and yield syntax is what stops people cold. Generator functions aren't something most JavaScript developers use regularly, and the first time you see yield call(api.fetchData) instead of just await api.fetchData(), it raises obvious questions about why you'd add that layer of indirection.

The answer becomes clear when complexity hits. takeEvery, takeLatest, race, cancel — these are built-in Saga effects for patterns that Thunk handles awkwardly at best. takeLatest automatically cancels any previous in-flight request when a new one fires. One line. In Thunk that's a manual cancellation token, a ref, conditional dispatch logic. It's doable but it's work.

Once generators click — and it takes a few days of actual use, not just reading about them — the sequential-looking flow of a saga is easier to reason about than deeply nested promise chains. That's the tradeoff: steeper upfront cost, better ceiling.

The testing difference is bigger than people realize

This is something the comparison articles usually gloss over.
Testing a thunk is quick:

ja

vascript
const store = mockStore({});
await store.dispatch(fetchData());
expect(store.getActions()).toEqual([{ type: 'FETCH_SUCCESS', payload: mockData }]);
Enter fullscreen mode Exit fullscreen mode

You call it, check what got dispatched, done. If you're moving fast and the async logic is simple, this is a real advantage.
Testing a saga is more involved. You're stepping through a generator, asserting on each yield effect:

javascript
const gen = fetchDataSaga();
expect(gen.next().value).toEqual(call(api.fetchData));
expect(gen.next(mockData).value).toEqual(put({ type: 'FETCH_SUCCESS', payload: mockData }));
Enter fullscreen mode Exit fullscreen mode

There's more setup. Libraries like redux-saga-test-plan help significantly. The end result is actually more thorough coverage of your async behavior — you're testing the logic of the saga step by step, not just the final dispatched actions. But it takes longer to write. That's just true.

So which one

Genuinely depends on where your app is and where it's going.

Simple app, a handful of API calls, standard loading/error states, team that needs to move quickly — Thunk. There's no complexity justifying the Saga investment and adding it anyway just creates overhead without payoff.

Larger app, async operations that need to cancel each other, coordinate with each other, debounce, handle race conditions — Saga. The generator model that feels awkward in week one becomes the thing you're grateful for in month six when you need to add cancellation to something and it takes an hour instead of a day.

The mistake I see most often is teams adopting Saga on a simple app because it seems more "professional," then spending weeks onboarding developers onto generator syntax for async logic that a few thunks would have handled fine. The other mistake is teams sticking with Thunk on a growing application well past the point where the complexity is screaming for something more structured.

Neither is universally better. They solve different versions of the same problem.


At Innostax, we've helped React teams make this exact call — Thunk when the complexity didn't warrant more, Saga when it did, and migrated between the two when an app outgrew its original choice. If you're figuring out the right async architecture for your Redux application, innostax.com/contact is where that conversation happens.

Originally published on the Innostax Engineering Blog.

Top comments (0)