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 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.