Feb 19, 2025

Redux Thunk vs Redux Saga: The Simple Guide for Beginners

Tags

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

The Naive Approach
 (that didn't scale):


// 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
That's when I discovered Redux middleware for async.

redux-thunk-vs-redux-saga

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
That's when I discovered Redux Saga.


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


Feature
Thunk (Dashboard)
Saga (Chat)
Setup time
30 minutes
2 hours
API calls/sec
2-5
50+ (real-time)
Cancellation
Manual AbortController
Built-in cancel()
Error retry
Manual
Automatic with backoff
Testing
Mock fetch
Mock effects
Team learning curve
1 day
1 week
Production bugs
3 (race conditions)
0



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


Choose Redux Saga when:

✅ Real-time features (chat, notifications) ✅ Complex workflows (multi-step forms, wizards) ✅ Heavy cancellation needs ✅ Race condition nightmares ✅ Senior team comfortable with generators


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' });
  }
}


One saga handles manual clicks and background polling and cancellation. Thunk would need 3 separate functions + complex state tracking.


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.

The hybrid approach I use now:
  • RTK Query for data fetching (90% of needs)
  • Thunk for simple workflows
  • Saga for real-time/coordination (chat, multiplayer)

Start with Thunk. When you hit "I wish I could cancel this" or "these requests keep racing," that's your Saga signal.


EmoticonEmoticon