Suggest an editImprove this articleRefine the answer for “Redux Thunk vs Redux Saga - what is the difference?”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Redux Thunk vs Redux Saga** - Thunk action creators return functions that call `dispatch` after async work; Saga generators yield effect objects the middleware executes. ```javascript const loadThunk = (id) => (dispatch) => api.get(id).then(data => dispatch({ type: 'SUCCESS', payload: data })); function* loadSaga(action) { const data = yield call(api.get, action.payload); yield put({ type: 'SUCCESS', payload: data }); } ``` **Key point:** Thunk for simple API calls; Saga for cancellations, race conditions, and debounce.Shown above the full answer for quick recall.Answer (EN)Image**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 | Feature | Redux Thunk | Redux Saga | |---------|-------------|------------| | Core mechanism | Dispatched function runs async imperatively | Generator yields effects, middleware executes | | Async handling | `async/await` or promises inside thunk | `yield call()` for APIs, `yield put()` for dispatch | | Control flow | Linear, manual `try/catch` | Built-in `fork`, `join`, `race`, `cancel`, `retry` | | Testing | Mock `dispatch` and `getState` | Pure iterator checks, no mocks needed | | Bundle size | ~1KB | ~20KB + runtime | | Learning curve | 5 minutes | 1-2 hours (generators + effect model) | | Debounce/throttle | External library or manual timer | Built-in `debounce` and `throttle` effects | | When to use | Simple CRUD, basic data fetching | Real-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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.