Skip to the content.

Thunks and sagas both handle async — they just disagree on how

Thunks and sagas both handle async — they just disagree on how

Reducers must be pure, so the await has to live somewhere else. In the Redux world there are two established somewheres, and they represent genuinely different philosophies. A thunk is a function you dispatch — it runs, does its async work, and dispatches plain actions along the way. A saga is a long-running process that sits to the side, watches the stream of dispatched actions, and reacts. Thunks are imperative and local; sagas are declarative and centralised. Neither is “better” — they fit different amounts of async complexity, and picking by that is the whole decision.

A thunk runs inline on dispatch; a saga watches the action stream On the left a dispatched thunk runs its async work and dispatches results. On the right actions flow past a saga that is always listening and reacts to the ones it watches for. thunk dispatch fn runs await SUCCESS/FAIL saga action stream saga watches
A thunk is work triggered by a dispatch; a saga is a standing watcher that reacts to actions as they flow past.

A thunk is a function you dispatch

Thanks to the thunk middleware, dispatch accepts a function as well as an object. That function receives dispatch and getState, so it can bracket an async call with the request/success/fail triple:

const loadUser = (id) => async (dispatch, getState) => {
  dispatch({ type: "USER_REQUEST" });
  try {
    const res = await fetch(`/api/users/${id}`);
    dispatch({ type: "USER_SUCCESS", data: await res.json() });
  } catch (err) {
    dispatch({ type: "USER_FAIL", error: err.message });
  }
};

dispatch(loadUser(42));   // the function runs, drives the async, dispatches results

The logic is right there, imperative and readable. For the vast majority of apps — fetch on mount, submit a form, load more — this is all you ever need.

A saga watches and reacts declaratively

Sagas invert the control flow. You write a generator that listens for an action type and yields declarative effects (call, put, takeLatest) that the middleware runs. The payoff is orchestration: cancellation, debouncing, racing, and “wait for A then B” become first-class:

import { call, put, takeLatest } from "redux-saga/effects";

function* loadUser(action) {
  yield put({ type: "USER_REQUEST" });
  try {
    const data = yield call(fetchUser, action.id);   // "call this", not "await this"
    yield put({ type: "USER_SUCCESS", data });
  } catch (err) {
    yield put({ type: "USER_FAIL", error: err.message });
  }
}

// takeLatest auto-cancels an in-flight load when a newer one starts
function* watch() { yield takeLatest("USER_LOAD", loadUser); }

takeLatest alone — cancel the previous request when a new one arrives — is tedious to hand-roll in a thunk and one word in a saga. That is the kind of problem sagas exist for.

Pick by the shape of your async, not by fashion

The honest rule: reach for thunks by default. They are less code, less concept, and cover fetch-and-store cleanly. Reach for sagas when your async has genuine process — complex cancellation, cross-action coordination, websocket streams, retry-with-backoff sequences, long workflows that span many events. If you can describe your need as “when this happens, go do that,” a thunk is fine; if you need “watch for this, and while it runs, also do that, unless this other thing happens first,” that is a saga. Both are just middleware turning an async story into plain actions the pure reducers can handle. The apply-middleware exercise builds the seam both plug into, which is the best way to see they are two answers to the same structural question.