Skip to main content

Redux Thunk vs Redux Saga - what is the difference?

Redux Thunk vs Redux Saga - Thunk lets action creators return functions that call dispatch after async work completes; Saga uses generator functions that yield effect objects, and the middleware interprets and executes them.

Theory

TL;DR

  • Thunk: action creator returns a function, that function calls dispatch after async work
  • Saga: generator function yields effect descriptors (call, put, take), middleware does the actual work
  • Analogy: Thunk is a vending machine button, press it and get a result later. Saga is a factory supervisor, assigns tasks, monitors each step, redirects on failure
  • Decision: single API call or basic CRUD? Thunk. Cancellation, polling, race conditions, debounce? Saga
  • Bundle gap: Thunk ~1KB, Saga ~20KB with runtime overhead

Quick example

Both handle the same FETCH_USER_REQUEST action:

javascript
// Thunk - action creator returns a function, not a plain object const fetchUser = (id) => (dispatch) => { dispatch({ type: 'FETCH_USER_REQUEST' }); fetch(`/api/user/${id}`) .then(res => res.json()) .then(user => dispatch({ type: 'FETCH_USER_SUCCESS', payload: user })) .catch(err => dispatch({ type: 'FETCH_USER_FAILURE', payload: err })); }; // dispatch(fetchUser(123)) - thunk middleware detects a function and calls it
javascript
// Saga - generator yields effects for middleware to execute import { call, put, takeEvery } from 'redux-saga/effects'; function* fetchUserSaga(action) { try { const user = yield call(fetch, `/api/user/${action.payload}`); yield put({ type: 'FETCH_USER_SUCCESS', payload: user }); } catch (error) { yield put({ type: 'FETCH_USER_FAILURE', payload: error }); } } // takeEvery('FETCH_USER_REQUEST', fetchUserSaga) - listens and runs on each action

With Thunk, the function runs directly when dispatched. With Saga, call(fetch, ...) is a plain JavaScript object. The middleware reads it, calls fetch, then resumes the generator with the resolved value.

Key difference

Thunk middleware checks typeof action === 'function'. If true, it calls action(dispatch, getState) synchronously and returns the result. That is the entire Thunk implementation, under 10 lines of code. Saga runs generators via next() in a loop, treating each yielded object as an instruction: call means "run this async function", put means "dispatch this action", take means "wait for this action type". Because those instructions are plain objects, you can test Saga logic by checking what was yielded, with no mocking required.

When to use

  • Single API call, no coordination between requests: Thunk, fewer files, no generator syntax to learn
  • Debounce on a search input: Saga has a built-in debounce effect, Thunk needs an external utility or a manual timer
  • Multiple parallel requests: Saga's all([...]) handles this cleanly
  • Race conditions (first API wins, cancel the other): Saga's race({...}) does it in 3 lines
  • Cancellation when a user navigates away mid-request: Saga's cancel() is built in, Thunk needs AbortController wired manually
  • Polling (check a server every N seconds): Saga's while(true) with delay() pattern, Thunk gets messy
  • App with fewer than 10 async actions: Thunk, avoid the overhead

Comparison table

FeatureRedux ThunkRedux Saga
Core mechanismDispatched function runs async imperativelyGenerator yields effects, middleware executes
Async handlingasync/await or promises inside thunkyield call() for APIs, yield put() for dispatch
Control flowLinear, manual try/catchBuilt-in fork, join, race, cancel, retry
TestingMock dispatch and getStatePure iterator checks, no mocks needed
Bundle size~1KB~20KB + runtime
Learning curve5 minutes1-2 hours (generators + effect model)
Debounce/throttleExternal library or manual timerBuilt-in debounce and throttle effects
When to useSimple CRUD, basic data fetchingReal-time apps, complex async orchestration

How the middleware handles this

Thunk intercepts store.dispatch(action). If typeof action === 'function', it calls action(dispatch, getState) synchronously and returns whatever the function returns. That is essentially the full source code of Redux Thunk.

Saga middleware starts the root saga generator and drives it with a next(resolvedValue) loop. When the generator yields call(fn, args), the middleware calls fn(...args), waits for the promise to settle, then resumes the generator with the result. If the promise rejects, it throws into the generator at the yield point. put(action) queues a dispatch. take(pattern) suspends the generator until a matching action arrives. This step-by-step execution is what makes Saga possible to pause, cancel, and test without mocking.

Common mistakes

1. Thunk: skipping getState for dependent logic

javascript
// Wrong - fetches without checking the cache const fetchUser = (id) => async (dispatch) => { const user = await api.get(id); dispatch({ type: 'FETCH_USER_SUCCESS', payload: user }); }; // Right - avoid duplicate requests const fetchUser = (id) => async (dispatch, getState) => { if (getState().users[id]) return; // already in store const user = await api.get(id); dispatch({ type: 'FETCH_USER_SUCCESS', payload: user }); };

2. Saga: calling the API directly without yield call()

javascript
// Wrong - synchronous call, api.getUser returns a Promise object, not resolved data function* fetchUserSaga(action) { const user = api.getUser(action.payload); // not yielded yield put({ type: 'FETCH_USER_SUCCESS', payload: user }); // user is a Promise, not data } // Right function* fetchUserSaga(action) { const user = yield call(api.getUser, action.payload); yield put({ type: 'FETCH_USER_SUCCESS', payload: user }); }

3. Saga: takeEvery that triggers on its own dispatches

javascript
// Wrong - if the handler dispatches the same action type, it re-triggers itself function* watchFetch() { yield takeEvery('FETCH_DATA', handleFetch); } // Right - takeLatest cancels the previous task on each new action function* watchFetch() { yield takeLatest('FETCH_DATA', handleFetch); }

4. Thunk: no cleanup after component unmount

javascript
// Wrong - dispatches to the store after the component is gone const fetchUser = (id) => async (dispatch) => { const user = await api.get(id); dispatch({ type: 'FETCH_USER_SUCCESS', payload: user }); // React warning if unmounted }; // Right - AbortController signals the fetch to stop const fetchUser = (id, signal) => async (dispatch) => { try { const user = await api.get(id, { signal }); dispatch({ type: 'FETCH_USER_SUCCESS', payload: user }); } catch (err) { if (err.name !== 'AbortError') dispatch({ type: 'FETCH_USER_FAILURE', payload: err }); } }; // In component useEffect: const ctrl = new AbortController(); // dispatch(fetchUser(id, ctrl.signal)); return () => ctrl.abort();

Real-world usage

  • Create React App and Redux Toolkit ship with Thunk configured by default
  • Stream Chat SDK uses Saga for WebSocket orchestration and reconnection logic
  • Next.js apps typically use Thunk for SSR data fetching (getServerSideProps), Saga for WebSocket or polling flows
  • Electron desktop apps use Saga to manage file I/O races, like canceling a file read when a tab closes
  • In most production React apps I've reviewed, teams start with Thunk and bolt on Saga only when a specific flow gets complicated enough to justify the overhead

Follow-up questions

Q: How does Saga handle promise rejections inside call()?
A: When the promise from call() rejects, Saga throws the error into the generator at the yield point. A try/catch around yield call(...) catches it, the same way async/await does.

Q: Can you cancel a running Thunk?
A: Not natively. You add AbortController manually and call controller.abort(), usually in a useEffect cleanup. Saga's cancel() and takeLatest handle cancellation without extra wiring.

Q: What is the performance difference for 100 concurrent requests?
A: Thunk creates 100 independent functions in flight with no coordination. Saga forks lightweight tasks with lower memory per task, but generators carry their own overhead. For most apps the difference is negligible, but Saga's structured concurrency prevents runaway tasks.

Q: What is the difference between fork and spawn in Saga?
A: fork creates a task attached to the parent - if the child throws, the error propagates up. spawn creates a detached task - if it throws, the parent saga continues unaffected. Use spawn for fire-and-forget background work.

Q: (Senior) How does select() interact with a blocking take() effect?
A: select() is non-blocking - it reads the current store state immediately and resumes the generator in the same tick. take(pattern) suspends the generator until a matching action arrives. If you write yield take('X'); yield select(...), the select reads state after X has already been processed by reducers. Thunk's getState() is also synchronous, but since it runs inside a plain function rather than a paused generator, the timing semantics differ - there is no equivalent of "waiting for a specific action" without external coordination.

Q: What breaks when migrating a Thunk app to Saga?
A: Components that do await dispatch(thunkAction()) and rely on the returned promise. Saga dispatches do not return values, so that pattern stops working. You need to restructure around action listening, callback channels, or eventChannel.

Examples

Basic: fetch user data on request action

javascript
// Thunk - fetchUser returns a function, not a plain action object const fetchUser = (id) => async (dispatch) => { dispatch({ type: 'FETCH_USER_REQUEST' }); try { const res = await fetch(`/api/users/${id}`); const user = await res.json(); dispatch({ type: 'FETCH_USER_SUCCESS', payload: user }); } catch (err) { dispatch({ type: 'FETCH_USER_FAILURE', payload: err.message }); } }; dispatch(fetchUser(42)); // thunk middleware intercepts and calls the inner function
javascript
// Saga - generator handles the same flow declaratively import { call, put, takeLatest } from 'redux-saga/effects'; function* fetchUserSaga({ payload: id }) { try { const res = yield call(fetch, `/api/users/${id}`); const user = yield call([res, 'json']); // call a method on an object yield put({ type: 'FETCH_USER_SUCCESS', payload: user }); } catch (err) { yield put({ type: 'FETCH_USER_FAILURE', payload: err.message }); } } export function* watchUsers() { yield takeLatest('FETCH_USER_REQUEST', fetchUserSaga); // takeLatest cancels the previous fetch if a new REQUEST arrives before it finishes }

takeLatest automatically cancels the in-flight fetch when a second FETCH_USER_REQUEST arrives. Getting the same behavior with Thunk requires manual AbortController setup.

Intermediate: search input with debounce

With Thunk, you store a timer at module level and manage it yourself:

javascript
// Thunk - manual debounce with a module-level timer let searchTimer; const searchUsers = (query) => (dispatch) => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { dispatch({ type: 'SEARCH_USERS_REQUEST' }); api.searchUsers(query) .then(users => dispatch({ type: 'SEARCH_USERS_SUCCESS', payload: users })) .catch(() => dispatch({ type: 'SEARCH_USERS_FAILURE' })); }, 300); }; // Problem: module-level timer persists across tests and hot reloads

Saga ships with a debounce effect:

javascript
// Saga - built-in debounce, no external state import { debounce, call, put } from 'redux-saga/effects'; function* searchUsersSaga({ payload: query }) { try { const users = yield call(api.searchUsers, query); yield put({ type: 'SEARCH_USERS_SUCCESS', payload: users }); } catch { yield put({ type: 'SEARCH_USERS_FAILURE' }); } } export function* watchSearch() { yield debounce(300, 'SEARCH_USERS', searchUsersSaga); // waits 300ms after the last SEARCH_USERS action before calling searchUsersSaga }

The Saga version has no module-level state and resets cleanly between tests. It is also shorter.

Advanced: fallback API with race condition

javascript
// Thunk - Promise.race with manual null-checking, losing request keeps running const searchWithFallback = (query) => (dispatch) => Promise.race([ api.searchPrimary(query).catch(() => null), api.searchFallback(query).catch(() => null) ]) .then(result => { if (!result) return dispatch({ type: 'SEARCH_FAILURE' }); dispatch({ type: 'SEARCH_SUCCESS', payload: result }); }); // The losing request keeps running in the background - no cancellation
javascript
// Saga - race effect cancels the loser automatically import { race, call, put } from 'redux-saga/effects'; function* searchWithFallback({ payload: query }) { const { primary, fallback } = yield race({ primary: call(api.searchPrimary, query), fallback: call(api.searchFallback, query) }); if (primary) yield put({ type: 'SEARCH_SUCCESS', payload: primary }); else if (fallback) yield put({ type: 'SEARCH_SUCCESS', payload: fallback }); else yield put({ type: 'SEARCH_FAILURE' }); } // When primary resolves first, Saga cancels the fallback call automatically

When primary resolves, Saga cancels fallback. The Thunk version lets both requests run to completion regardless of which wins. On mobile or slow connections, that unused request wastes bandwidth every time.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?