The Complete Guide to Redux Saga With Real life example

Managing Side Effects in Your Redux App

·

15 min read

Introduction

Redux Saga is like a battlefield commander coordinating complex missions behind the scenes. Just like a commander carefully manages troop logistics and strategies, Redux Saga orchestrates asynchronous flows and side effects outside of your React components.

Without Saga, components become clogged with tangled asynchronous logic and business rules. As an app grows, this makes it harder to understand, maintain, and extend. Redux Saga provides the missing coordination layer to scale apps gracefully

1. What is Redux Saga?

Redux Saga is a middleware library for managing side effects in your Redux application. Side effects refer to anything that affects state change in your app outside of standard actions - things like asynchronous API calls, web socket communications, and accessing browser data and APIs.

Redux Saga provides an easier way to handle all of these complex workflows right inside your Redux store. It uses an ES6 feature called generators to make asynchronous code look synchronous.

The main benefits of Redux Saga include:

  • Making side effects like data fetching and impure functions easier to manage

  • Simple control flow for complex asynchronous operations

  • Better separation of business logic from component code

  • Easier testing using sagas as pure functions

Overall, Redux Saga aims to help you better organize your Redux applications by moving side effects outside your components and into separate sagas. This makes your code more maintainable and scalable as your app grows.

2. Setting Up Redux Saga

The Redux Saga library can be installed via NPM or Yarn:

npm install redux-saga

or

yarn add redux-saga

To integrate Redux Saga into your application, you will need to:

  1. Create a saga middleware

  2. Connect the saga middleware to the Redux store

  3. Import and run sagas from the root saga

  4. Attach the saga middleware to the store

Here is an example setup with a simple root saga:

// saga.js

import { takeEvery } from 'redux-saga/effects';

function* watchFetchDataSaga() {
  yield takeEvery('FETCH_DATA', fetchDataSaga); 
}

export default function* rootSaga() {
  yield all([
    watchFetchDataSaga()
  ]);
}

// configureStore.js

import { createStore, applyMiddleware } from 'redux';
import createSagaMiddleware from 'redux-saga';

import rootReducer from './reducers';
import rootSaga from './sagas';

const sagaMiddleware = createSagaMiddleware();

const store = createStore(
  rootReducer,
  applyMiddleware(sagaMiddleware)
);

sagaMiddleware.run(rootSaga);

export default store;

This hooks up the Redux Saga middleware to listen for dispatched actions and run our sagas.

3.Basic Concepts of Redux Saga

Some key concepts to understand when working with Redux Saga:

Sagas: Generator functions that are triggered by Redux actions and can also dispatch Redux actions. Used for managing side effects.

Effects: Helper functions provided by Redux Saga to call external APIs, dispatch actions, and more.

Tasks: Units of execution scheduled by a saga using effects like take and fork.

Middleware: Redux Saga middleware is created using createSagaMiddleware and connect to the Redux store. This allows sagas to listen and respond to dispatched actions.

A basic saga workflow goes as follows:

  1. Actions are dispatched to the Redux store

  2. Saga middleware triggers matching sagas to run

  3. Sagas execute effects like API calls, dispatching actions

  4. Actions are dispatched by sagas back to reducers

  5. Store state updates accordingly

To demonstrate the core concepts, let's look at a simple user login flow:

Sagas - loginSaga handles the full login process. It dispatches actions to components and invokes APIs.

Effects - call is used to call the API login function. put dispatches actions.

Tasks - validateUser could fork a task to validate the user in the background.

// sagas.js
function* loginSaga(action) {

  try { 
    // call API
    const user = yield call(api.login, action.credentials);  

    // dispatch success action
    yield put({type: 'LOGIN_SUCCESS', user});

  } catch (err) {
    yield put({type: 'LOGIN_ERROR', err});
  }

}

function* validateUser(user) {
  // fork validation task
  // dispatches actions on complete  
}

This shows how sagas coordinate the overall flow while effects handle individual steps.

This allows for keeping business logic separated from components and managing complex workflows in sagas.

4. Understanding Generator Functions

Generator functions provide a powerful capability in JavaScript - the ability to pause and resume function execution. This enables writing asynchronous, non-blocking code in a simple synchronous style.

Let's look at how they work:

When a regular function is called, it runs straight through and returns a value:

function fetchUser() {
  // do work
  return user 
}

const user = fetchUser()

But generator functions are different. When called, they return a generator object without running any code:

function* fetchUserSaga() {
  // function body
}

const genObj = fetchUserSaga() // no code executed yet

To execute the generator, you call next() on the generator object. This runs the code until the first yield expression:

genObj.next() 

// Runs until first yield:

function* fetchUserSaga() {
  // start execution

  yield put(actions.requestStart())

  // execution pauses here on yield
}

The yield keyword pauses the function execution and returns control to the caller of next(). It also returns the value following it (like put() here) out to the caller.

Later, calling next() again resumes where it left off until the next yield:

genObj.next() // dispatch requestStart 

genObj.next() // resume execution

// Runs next lines:

function* fetchUserSaga() {

  // already executed 

  const user = yield call(Api.fetchUser) // pauses again

  // will run after next next() call
}

This start/stop ability enables writing asynchronous flows in a synchronous-looking style. The generator manages state between yields.

Redux Saga uses this heavily, yielding effects like put and call to orchestrate complex async workflows. So generators power the synchronous coding style for sagas.

Here is a complete example demonstrating generator functions and how Redux Saga utilizes them:

// Generator function 
function* fetchUserSaga() {

  // Yield effects like put() and call()
  // to pause execution

  console.log('Starting saga...')

  // Pause, dispatch action
  yield put({type: 'FETCH_USER_STARTED'}) 

  // Pause again, wait for API call
  const user = yield call(fetchUserFromAPI)

  // Resume, dispatch success action 
  yield put({type: 'FETCH_USER_SUCCEEDED', user})

  console.log('Finishing saga...')
}

// Calling the generator function 
// returns a generator object
const sagaGenObj = fetchUserSaga()

// Run the generator by calling next()
sagaGenObj.next() // Logs "Starting saga..."

// Next call runs until first yield
sagaGenObj.next() // Dispatches "FETCH_USER_STARTED" 

// Simulate asynchronous operation
setTimeout(() => {

  // Resume where previous yield left off
  sagaGenObj.next(mockUserData) 

  // Runs until next yield
  sagaGenObj.next() // Dispatches "FETCH_USER_SUCCEEDED"

  console.log('Saga complete!')

}, 2000)

In this example, we see how the generator function fetchUserSaga can be paused and resumed based on yielding effects and calling next().

The core ideas are:

  • Generator functions allow starting and stopping execution

  • The yield keyword returns data and pauses execution

  • Calling next() resumes execution until the next yield

  • This enables writing async flows in a synchronous style

Redux Saga uses these generator capabilities to orchestrate complex async logic for side effects. The saga middleware handles running the generators and effects behind the scenes.

5. Working with Effects

Redux Saga provides various effects that make managing side effects easier. Some common effects include:

take - Pauses saga execution until specified action is dispatched

put - Dispatches action to the Redux store

call - Calls a function, returns result

fork - Forks a separate task in the background

select - Selects state from the Redux store

Let's check with example :

take - When take is yielded, the saga middleware will pause execution and subscribe to the provided action type. When that action is dispatched, take will resume and return the action object.

// Wait for action
const {payload} = yield take('FETCH_PRODUCTS')

take.maybe - Same as take but does not pause execution. Can be used to conditionally run logic on an action.

put - When put is yielded, the saga middleware will dispatch the provided action object to the Redux store.

yield put({type: 'ADD_PRODUCT', payload})

call - When call is yielded, the saga will call the provided function and pause until it returns. It returns the result to the saga.

const data = yield call(fetchAPI)

apply - Similar to call but used for calling a function and passing arguments from the saga.

fork - When fork is yielded, the saga will spawn a new task that runs in the background without blocking execution.

yield fork(handleAsyncLogic) // runs in parallel

join - Blocks execution until a forked task finishes. Joins on the provided task identifier.

race - Runs multiple effects concurrently and resumes on the first to finish.

So in summary, effects like take and call pause saga execution while put, fork schedule work to occur. This provides a powerful declarative API for orchestrating async flows and coordinating actions.

Let me know if you would like any clarification or have additional questions!

6. Handling Asynchronous Operations

Here is a more in-depth explanation of handling asynchronous operations with Redux Saga:

Managing asynchronous flows is where Redux Saga shines. With generator functions and effects, Sagas enable coordinating async logic in a synchronous-looking style.

For example, here is a saga that fetches user data from an API:

// Fetches user data asynchronously
function* fetchUserSaga(action) {

  try {

    // Indicate pending state
    yield put({type: 'FETCH_USER_PENDING'})

    // Call API using call effect
    const user = yield call(api.fetchUser, action.userId)

    // Dispatch success action 
    yield put({type: 'FETCH_USER_FULFILLED', payload: user})

  } catch (err) {

    // Dispatch failure action
    yield put({type: 'FETCH_USER_REJECTED', error: err})

  }
}

Here is what happens step-by-step:

  1. The saga is triggered by a FETCH_USER action containing the userId to fetch

  2. It first dispatches a pending action to update state

  3. It then calls the async API function using the call effect

  4. On success, it dispatches a success action with the user data

  5. On failure, it dispatches a failure action containing the error

This allows the component dispatching the action to stay in sync with the async request status. It's kept completely separate from how the API call and data fetching is orchestrated.

Sagas can also watch actions using take effects:

// Watches for FETCH_USER action
function* watchFetchUser() {
  while(true) {
    const action = yield take('FETCH_USER')
    yield fetchUserSaga(action) 
  }
}

This allows sagas to orchestrate async flows in response to dispatched Redux actions. By using effects like call, put and take, complex async workflows can be managed elegantly and purely.

7. Combining Multiple Sagas

For any real application, you'll need to organize and combine multiple sagas.

Managing asynchronous flows is where Redux Saga shines. With generator functions and effects, Sagas enable coordinating async logic in a synchronous-looking style.

For example, here is a saga that fetches user data from an API:

// Fetches user data asynchronously
function* fetchUserSaga(action) {

  try {

    // Indicate pending state
    yield put({type: 'FETCH_USER_PENDING'})

    // Call API using call effect
    const user = yield call(api.fetchUser, action.userId)

    // Dispatch success action 
    yield put({type: 'FETCH_USER_FULFILLED', payload: user})

  } catch (err) {

    // Dispatch failure action
    yield put({type: 'FETCH_USER_REJECTED', error: err})

  }
}

Here is what happens step-by-step:

  1. The saga is triggered by a FETCH_USER action containing the userId to fetch

  2. It first dispatches a pending action to update state

  3. It then calls the async API function using the call effect

  4. On success, it dispatches a success action with the user data

  5. On failure, it dispatches a failure action containing the error

This allows the component dispatching the action to stay in sync with the async request status. It's kept completely separate from how the API call and data fetching is orchestrated.

Sagas can also watch actions using take effects:

// Watches for FETCH_USER action
function* watchFetchUser() {
  while(true) {
    const action = yield take('FETCH_USER')
    yield fetchUserSaga(action) 
  }
}

This allows sagas to orchestrate async flows in response to dispatched Redux actions. By using effects like call, put and take, complex async workflows can be managed elegantly and purely.

Here are some additional details on combining multiple sagas in a modular way:

Splitting sagas into multiple files helps organize different domains, features, and workflows in your application. Some common ways to split them are:

  • By route or page

  • By entity like users, products, cart

  • By workflow like auth, checkout, payments

  • By feature like analytics, messaging, notifications

For example, the auth sagas may include:

authSagas.js

import { call, put } from 'redux-saga/effects'

// Log in saga
export function* logInSaga(action) {
  //...
} 

// Sign up saga 
export function* signUpSaga(action) {
  // ...
}

These auth-related sagas can be combined into a rootSaga:

rootSaga.js

import { all } from 'redux-saga/effects'
import * as authSagas from './authSagas'

export default function* rootSaga() {
  yield all([
    authSagas.logInSaga(),
    authSagas.signUpSaga() 
    // other sagas
  ])
}

The all effect runs the provided sagas concurrently. This allows independent workflows to progress simultaneously.

Other useful effects for combining sagas include:

  • race - Runs sagas concurrently, cancel others on completion

  • join - Blocks execution until specified sagas complete

  • fork - Spawns sagas in background without blocking

Properly splitting sagas improves code organization, isolates concerns, and can help performance by running tasks concurrently when possible.

8.Since sagas are generator functions, try/catch blocks can be used to handle errors:

jsCopy codefunction* fetchUserSaga(action) {

  try {
    // Call API 
    const user = yield call(api.fetchUser, action.userId)

    // Dispatch success action
    yield put(actions.fetchSuccess(user))

  } catch (error) {
    // Dispatch failure action 
    yield put(actions.fetchFailure(error)) 
  }

}

The call effect returns normal values or throws exceptions just like a regular function. So surrounding API calls with try/catch allows catching failures.

For API errors, the saga can dispatch appropriate actions to update state. For other errors like programming errors, a saga could log to the console before aborting.

Testing Sagas

Sagas lend themselves well to testing since they are pure functions with no side effects inside the saga. The redux-saga-test-plan library provides a simple API for asserting effects:

jsCopy codeconst fetchUser = testSaga(fetchUserSaga)

fetchUser
  .put(actions.requestStart())
  .call(api.fetchUser, userId)
  .put(actions.requestSuccess(user))

This validates the saga yields the expected effects in order. Any errors will fail the assertion.

So sagas enable:

  • Catching errors within sagas using try/catch

  • Easily testing sagas since they are pure functions

  • Isolating side effects from components

Proper error handling and testing ensures sagas remain robust and reliable for orchestrating complex flows.

9. Real-World Use Cases

There are many scenarios where Redux Saga simplifies complex flows:

Data Fetching: Fetching data from multiple sources, handling loading and error states

Authentication: Log in/out flows, redirection on auth events, account verification

Real-time: Web socket communication, live data streams

API Request Management: Caching, retries, polling, request deduplication

Analytics: Tracking user events, page views, performance data

Asynchronous UX: Auto-saving, notifications, location-based triggers

These types of complex workflows with multiple moving parts are perfect for sagas.

Here is an example real world codebase using Redux Saga for state management and side effects:

E-Commerce App with Redux Saga

This example implements common e-commerce workflows like authentication, product browsing, cart management, and checkout in a React + Redux app using Redux Saga for side effect handling.

Project Structure

src/
|- components/ - presentational React components
|- redux/
  |- slices/ - Slice reducers and actions
  |- sagas/ - Redux Saga files    
  |- store.js - Redux store setup
|- App.js - Root component
|- index.js - Entry point

Key files:

store.js:

// Create saga middleware
const sagaMiddleware = createSagaMiddleware()

// Mount saga middleware on store
const store = configureStore({
  reducer: rootReducer,
  middleware: [sagaMiddleware]  
})

// Run saga monitor for logging
sagaMonitor.setEnabled()

// Run root saga
sagaMiddleware.run(rootSaga)

export default store

rootSaga.js

// Root saga combines all feature sagas
export default function* rootSaga() {
  yield all([
    authSagas(),
    productsSagas(),
    cartSagas(),
    ordersSagas()
  ])
}

Authentication Sagas

The auth sagas handle login, logout and user session handling.

sagas/auth.js

import { call, put, take } from 'redux-saga/effects'
import { loginSuccess, loginFailed } from '../slices/auth'

// Worker saga - log user in
export function* loginSaga(action) {

  try {
    // Call API endpoint  
    const user = yield call(api.login, action.payload)  

    // Dispatch success action
    yield put(loginSuccess(user))

  } catch (error) {
    // Dispatch failure action
    yield put(loginFailed(error)) 
  }
}

// Watcher saga - watch for login request
export function* watchLoginSaga() {
  while (true) {
    const action = yield take('LOGIN_REQUEST')
    yield loginSaga(action)
  }
}

The watchLoginSaga waits for the LOGIN_REQUEST action and triggers the worker saga. The worker handles calling the API and dispatching appropriate actions on success/failure.

Logout and session handling follows a similar pattern.

Products Sagas

These sagas handle product data fetching.

sagas/products.js

import { call, put } from 'redux-saga/effects'
import { fetchProductsSuccess } from '../slices/products'

// Fetch products from API
export function* fetchProductsSaga() {

  try {  
    const products = yield call(api.fetchProducts)
    yield put(fetchProductsSuccess(products))

  } catch (error) {
    // Handle error
  }
} 

export function* watchFetchProducts() {
  yield take('FETCH_PRODUCTS')
  yield fetchProductsSaga()
}

This pattern fetches products when the FETCH_PRODUCTS action is dispatched. The products slice handles updating the state.

Cart Sagas

Manages cart actions like adding/removing items and checkout.

sagas/cart.js

import { take, call, put, select } from 'redux-saga/effects'
import { getCart } from '../slices/cart'

// Add item to cart
export function* addToCartSaga(action) {

  // Get current cart from state
  const state = yield select(getCart)

  const itemExists = state.items.find(...) 

  if (itemExists) {
    // Increment quantity  
  } else {  
    // Add new item
  }

  // Call API to update cart  
  yield call(api.updateCart, updatedCart)

  // Dispatch cart updated action
  yield put(cartUpdated(updatedCart)) 

}

// Watch for add to cart action
export function* watchAddToCart() {
  yield take('ADD_TO_CART')
  yield addToCartSaga()
}

The cart sagas demonstrate selecting state, calling APIs, and dispatching actions to coordinate cart workflows.

Summary

Some key points:

  • Sagas are split by domain for organization

  • Watcher sagas respond to dispatched actions

  • Worker sagas handle business logic and side effects

  • Effects like call, put, take simplify async logic and flows

  • Error handling with try/catch blocks

  • Components stay focused on UI

This structure keeps components simple and business logic separate. Scaling and modifying workflows is easy since sagas are modular.

Redux Saga enables managing complexity in Redux through side effect handling and workflow orchestration.

10. Best Practices and Tips

Here are some tips for using Redux Saga effectively:

  • Keep sagas focused on a single task or domain

  • Favor small generator functions over large complex sagas

  • Use descriptive saga names like watchAuthSaga

  • Prefer declarative effects like take and put over imperative code

  • Minimize branching and conditionals in sagas

  • Use takeLatest for actions that should cancel previous calls

  • Leverage selector functions for extracting data from state

  • Use sagas for orchestration, not calculation

Following best practices will help keep your sagas easy to test, understand and maintain.

11. Conclusion

Redux Saga provides a powerful abstraction for managing complex workflows in Redux apps. With generator functions and effects, it makes asynchronous flows feel simple and synchronous.

In this guide, we covered core concepts, practical examples, and real-world use cases for leveraging Redux Saga. From complex data fetching to long-running processes, Redux Saga can help you handle the most challenging side effects with ease.

By embracing sagas, you can build more resilient, testable and flexible applications on top of Redux. The community around Redux Saga is also actively growing with many addons and integrations available.

I encourage you to give Redux Saga a try in your next Redux app. Let me know if you have any other topics you'd like to see covered around Redux Saga!

12. Additional Resources

13. References

Did you find this article valuable?

Support Mikey by becoming a sponsor. Any amount is appreciated!