I remember the exact moment Redux Thunk saved my first big React project—and the moment I wished I'd chosen Redux Saga instead. It was 2019, and I was building an e-commerce dashboard with live inventory updates, user notifications, and background data sync. Thunk got me 80% there, but when users started canceling orders mid‑API call or when I needed to debounce search suggestions, I hit a wall.
Let me walk you through my journey with both, the real projects where each shined (and failed), and exactly when I'd pick one over the other today.
The project that forced me to learn async Redux
The Problem: We had a dashboard showing real‑time order status. Clicking "Refresh Orders" triggered an API call. Simple enough. But then came the requirements:
- Show a loading spinner during the fetch
- Handle network errors gracefully
- Cancel the request if users clicked refresh again
- Auto‑refresh every 30 seconds in the background
- Pause auto‑refresh when users were typing in search
// This broke immediately
function fetchOrders() {
return (dispatch) => {
dispatch({ type: 'LOADING_ORDERS' });
fetch('/api/orders')
.then(res => res.json())
.then(orders => dispatch({ type: 'ORDERS_LOADED', orders }));
};
}
Problems with naive approach:
- No way to cancel previous requests
- No auto‑refresh logic
- No error boundaries
- Multiple overlapping requests piled up
Redux Thunk: My first love (and still my default)
The Real Project: Order management dashboard for 50 stores
Redux Thunk is dead simple: instead of dispatching plain objects, you dispatch functions. Those functions receive dispatch and getState, letting you do whatever you want—API calls, timers, conditionals.
Here's the actual code I wrote that saved the project:
// Thunk action creator
export const fetchOrders = () => {
return async (dispatch, getState) => {
const { orders } = getState();
// Skip if already loading or has fresh data
if (orders.loading || (orders.lastUpdated && Date.now() - orders.lastUpdated < 30000)) {
return;
}
dispatch({ type: 'ORDERS_FETCH_START' });
try {
const response = await fetch('/api/orders');
const ordersData = await response.json();
dispatch({
type: 'ORDERS_FETCH_SUCCESS',
payload: ordersData,
receivedAt: Date.now()
});
} catch (error) {
dispatch({
type: 'ORDERS_FETCH_ERROR',
error: error.message
});
}
};
};
Why this worked so well:
- Crystal clear async/await flow
- Access to current state (getState())
- Natural error handling with try/catch
- Built‑in duplicate request prevention
- Dead simple to test
Attach to button:
<button onClick={() => dispatch(fetchOrders())}>
Refresh Orders
</button>
Thunk handled 90% of my needs perfectly. Most projects since then started with Thunk.
When Thunk failed me: The live chat nightmare
The Problem: Real‑time chat with typing indicators, message editing, and delivery receipts.
// This approach broke down
const sendMessage = (text) => async (dispatch) => {
dispatch({ type: 'MESSAGE_SENDING', payload: text });
const response = await fetch('/api/messages', {
method: 'POST',
body: JSON.stringify({ text })
});
// What if user hits send AGAIN while this is pending?
// What if they navigate away mid‑request?
// What if network flakes and we need retry logic?
};
Problems piling up:
- No request cancellation when users hit send multiple times
- No race condition handling (which response wins?)
- No retry logic for flaky mobile networks
- No cleanup when components unmount
Redux Saga: When you need an orchestra conductor
The Chat App Rewrite that made everything work:
Redux Saga uses generator functions (function*) and special effects like call, put, take, cancel. Think of it as a state machine that orchestrates your async flow.
import { call, put, takeEvery, select, cancel, delay } from 'redux-saga/effects';
function* sendMessageSaga(action) {
try {
yield put({ type: 'MESSAGE_SENDING', payload: action.payload });
// Make API call
const response = yield call(api.sendMessage, action.payload);
// Optimistically update UI
yield put({
type: 'MESSAGE_SENT',
payload: {
id: response.id,
text: action.payload,
timestamp: Date.now()
}
});
} catch (error) {
yield put({ type: 'MESSAGE_SEND_FAILED', error: error.message });
}
}
function* watchSendMessage() {
yield takeEvery('MESSAGE_SEND_REQUEST', sendMessageSaga);
}
The game‑changers Saga gave me:
1. Automatic cancellation
function* optimizedChatSaga() {
const task = yield fork(sendMessageSaga, action);
// Cancel if user navigates away or sends new message
yield take('MESSAGE_SEND_REQUEST');
yield cancel(task);
}
2. Retry with exponential backoff
function* sendWithRetry(action, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return yield call(api.sendMessage, action.payload);
} catch (error) {
if (i === retries - 1) throw error;
yield delay(1000 * Math.pow(2, i)); // 1s, 2s, 4s
}
}
}
3. Debounced typing indicators
function* watchTyping() {
yield debounce(500, 'USER_TYPING', typingSaga);
}
Side-by-side: Real project comparison
My decision matrix (after 50+ projects)
Choose Redux Thunk when:
✅ Simple CRUD apps ✅ 1-5 API calls per screen ✅ Junior team members ✅ Rapid prototyping ✅ Most actions are straightforward
Modern Alternative (RTK Query):
For new projects today, I'd use Redux Toolkit Query for 90% of API needs. It handles caching, invalidation, optimistic updates out of the box. Thunk/Saga only for truly custom workflows.
The actual code that converted me to Saga
This single feature made Saga worth the learning curve:
// Handle BOTH button click AND auto-refresh
function* watchOrders() {
// Manual refresh
yield takeEvery('FETCH_ORDERS_REQUEST', fetchOrdersSaga);
// Auto-refresh every 30s when screen is visible
while (yield select(isOrdersScreenVisible)) {
yield delay(30000);
yield put({ type: 'FETCH_ORDERS_REQUEST' });
}
}
Conclusion: My recommendation after 5 years
For 80% of apps: Redux Thunk + RTK Query. Simple, fast, team‑friendly.
For 20% complex cases: Redux Saga. The control is worth the complexity when you need it.
- RTK Query for data fetching (90% of needs)
- Thunk for simple workflows
- Saga for real-time/coordination (chat, multiplayer)
