Managing Redux Side Effects Using Redux Saga

M

If you have previously used Redux in a React project, you will eventually need to handle side effects (asynchronous) such as API requests or setting timeout and intervals since they are necessary while developing features for a “complex online application.”

The Redux design takes an action and a state and then returns a new state to offer predictable state change. There are no built-in mechanisms in this design for dealing with asynchronous updates to the Redux state tree.

Side effects in React-Redux

So, where in the Redux flow can we add our side effects? We could handle our side effects at the component level, then dispatch an action with the side effects resolved outcome.

import React from "react"
export default const App = () => {
    let id = 011;
    getUser = (id) => {
        Api.getUser().then(res => {
            let user = res.user
            dispatch({ type: "USER_FETCH_SUCCEEDED", user: user })
        })
            .catch(err => {
                dispatch({ type: "USER_FETCH_FAILED", message: err.message })
            })
    }

    return (
        <div>
            <button onClick={() => getUser(id)}>Fetch User</button>
        </div>
    );
}

However, the difficulty with this method is that in large applications, you may have numerous components that desire to retrieve a user, resulting in duplicate logic that is difficult to manage.

Another possibility is to move the async logic (side-effect) to the action creators. In redux, action creators are functions that return an action; a simple object with at least a type property.

const getData=(id)=> {
    return {
        type: "FETCH_DATA",
        payload: id
    };
};

We can use middleware like redux-thunk to dispatch functions/promises and put async logic in action creators. The middleware examines each action dispatched and, if it is a function, calls it with two arguments: the store’s dispatch method, which can be used to dispatch plain action objects when the asynchronous code has failed or succeeded, and the store’s getState method, which is passed to it so the store can be read and used in the action creators.

eexport default function App(props) {
    const { id, dispatch } = props;
    return (
        <div>
            <button onClick={() => dispatch(requestUser(id))}>Fetch User</button>
        </div>
    );
}

// action creator(thunk)

const requestUser = (id) => {
    return function (dispatch) {
        return Api.fetchUser()
            .then(res => {
                dispatch({ type: "FETCH_USER_SUCCEEDED", user: res.user });
            })
            .catch(error => {
                dispatch({ type: "FETCH_USER_FAILED", message: error });
            });
    };
};

The fetch user API call is included in the action creator, so any component that needs to retrieve a user may use the requestUser() action instead of duplicating functionality.

The advantages of utilizing middleware like redux-thunk include:

  • The business logic may be reused by components since it is not strongly tied to the UI components.
  • Unlike redux-saga (which we will discuss later in this post), which requires you to master new JavaScript ideas in order to use it, it is simple to understand.

Additionally, it has a few drawbacks:

  • When dealing with complicated async logic in complex systems, it might become challenging to read and comprehend.
  • Advanced async actions like cancellation and debouncing are not supported in any form.

Despite its drawbacks, Redux-thunk is still the most used redux middleware for managing side effects. It is effective in the majority of use cases. Still, it fails in sophisticated applications where you might need to debounce a function call or terminate an API request after an action has been performed.

A middleware like redux-saga thrives in this difficult environment since it makes handling these situations simple by utilizing unique functions supplied by redux-saga.

Redux-saga also makes it possible to create declarative async code, making it more readable and testable. We will now go further into Es6 generators and Redux-saga, after which we will use the middleware to develop a simple application.

Redux-saga

Redux Saga is a middleware library used to allow a Redux store to interact with resources outside of itself asynchronously. This includes making HTTP requests to external services, accessing browser storage, and executing I/O operations. These operations are also known as side effects. Redux Saga helps to organize these side effects in a way that is easier to manage.

What is a Generator function?

A generator function may be interrupted and restarted while keeping its variable bindings intact (context). It is indicated by an asterisk before the function keyword.

function* App(){
//
//
}

Generators run until they reach a yield or return statement, as opposed to a typical JavaScript function, which continues until it reaches a return statement or has finished executing.

When a generator function is called, it does not instantly run the function body; instead, it returns an iterable/generator object that may be iterated or looped over using the iterator’s next() method or a for..

function* generator(i) {
  yield i;
  yield i + 10;
}
const gen = generator(10);
console.log(gen.next().value); // expected output: 10
console.log(gen.next().value); // expected output: 20

When you run a generator function and call its next() method, it runs the function until it encounters the first yield statement, at which point it pauses and returns an object with a value and done property. The value property is anything on the right-hand side of the keyword yield, and it can be an integer, a string, a function, an object, a promise, etc. The done property is a boolean that indicates whether it has finished iterating.

How does this relate to the Redux Saga then? We relocate our side effects from the action-creators to generator functions known as sagas in redux-saga. Redux-saga executes our sagas (generator functions), and it offers ways to deal with the side effects and communicate with the redux store. Redux-saga manages the process of calling next() on the generator object internally after executing our generator functions (sagas) that include the side effects.

The short story that makes a cup of tea in 5 minutes is below:

const delay = ms => new Promise(res => setTimeout(res, ms));
function* makeTea() {
	yield take({ type: "REQUEST_TEA" });
	yield delay(300000);
	yield put({ type: "TEA_DONE" });
};

This is simply a little example of a saga that provides objects to the redux-saga middleware. The yielded objects act as middleware interpretive instructions. You may have noted that the functions take, delay, and put in the saga above are helper functions that redux-saga provides.

yield take({ type: "REQUEST_TEA" });

take is an effect that directs the redux-saga middleware to wait for the store to dispatch the REQUEST_TEA action before continuing the execution of the generator function. So this function is delayed until REQUEST_TEA action is issued; once dispatched, it starts execution and executes until the next yield.

yield delay(300000);

delay is a function that returns a promise so when a promise is yielded to the redux-saga middleware, the middleware pauses the saga until the promise is resolved then execution resumes after 300 seconds. As I said earlier anything can be yielded by a generator function, it can be a function, promise, or value. But what redux-saga does here is that when it is yielded a promise, it has to wait for it to be resolved. This is quite similar to the async await way of handling promises.
So after 300 seconds, execution resumes until it gets to the final yield

yield put({ type: "TEA_DONE" });

put is another effect given by redux-saga that may be used to dispatch actions in a saga. As a result, this informs the middleware to send an TEA_DONE action to the store.

Redux-helpers saga’s effects include put and take. Effects are simple JavaScript objects that contain precise instructions for the middleware to follow.

In redux-saga, effects are divided into two categories blocking calls and non-blocking calls.

A blocking call indicates that the saga yielded an effect and will wait for the result of its execution before continuing execution inside the generator function.

A non-blocking call indicates that the saga will resume soon once the effect is produced.

Demo Application

We will be creating a simple application that calls the GIPHY API so that you can seamlessly integrate your app with random Cat GIF and Sticker, now that we have a basic understanding of generators and redux-saga.

The application will appear like this.

Folder Structure

index.js

import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import { createStore, applyMiddleware, compose } from "redux";
import createSagaMiddleware from "redux-saga";
import reducer from "./reducers";
import rootSaga from "./sagas";
import "bulma";
import App from "./App";

const sagaMiddleware = createSagaMiddleware();
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(reducer, composeEnhancers(
  applyMiddleware(sagaMiddleware)
));

sagaMiddleware.run(rootSaga);

const rootElement = document.getElementById("root");
ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  rootElement);

This is where our application starts. The App component is wrapped with the react-redux Provider in this file, giving it access to the redux store along with any component below it in the component tree if it is wrapped using the connect() function.
We configure the redux-store, the redux-saga middleware, and the redux-devtools in the same file.

const sagaMiddleware = createSagaMiddleware();
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(reducer, composeEnhancers(
  applyMiddleware(sagaMiddleware)
));

This line combines the redux-devtools and the saga middleware to create the store using the redux createStore function. The root reducer and enhancer are passed as the first and second arguments, respectively.

sagaMiddleware.run(rootSaga);

This line above dynamically runs the sagas, and it takes a saga as an argument.

App.js

import React from "react";
import VendingMachine from "./containers/VendingMachine";
function App() {
  return (
    <div className="section">
      <div className="container">
        <VendingMachine />
      </div>
    </div>
  );
}

export default App;

This is the parent component of our application, here we imported VendingMachine.js container and wrapped it in a div,

containers/VendingMachine.js

import { connect } from "react-redux";
import { gifActions } from "../actions";
import VendingMachine from "../components/VendingMachine";

const mapStateToProps = state => ({
  imageUrl: state.gif.url,
  loading: state.gif.loading,
  error: state.gif.error
});

const mapDispatchToProps = dispatch => ({
  play: () => dispatch(gifActions.asyncFetch()),
  clear: () => dispatch(gifActions.clearGif())
});

export default connect(mapStateToProps, mapDispatchToProps)(VendingMachine);

when it is about to mount it dispatches two actions to the redux store; asyncFetch which requests the random Gif and clearGif() which cancel the request and display the welcome message. here a vendingMachine.js component is imported from components folder and connected with mapStateToProps & mapDispatchToProps functions.

components/VendingMachine.js

import React from "react";
import PropTypes from "prop-types";
import Gif from "./Gif";

function VendingMachine({ imageUrl, loading, error, play, clear }) {
  return (
    <div>
      <h1 className="title">Cat Gif</h1>
      {renderGif({ imageUrl, loading, error })}
      <hr />
      <div className="buttons">
        <button className="button is-primary is-rounded" onClick={play}>                                                                 Play</button>
        <button className="button is-rounded" onClick={clear} >Clear</button>
      </div>
    </div>
  );
}

function renderGif({ imageUrl, loading, error }) {
  if (error) {
    return <p className="notification is-danger">Error!!</p>;
  }

  if (loading) {
    return <p className="notification is-info">Loading...</p>;
  }

  return imageUrl
    ? <Gif imageUrl={imageUrl} />
    : <p className="notification">Welcome!</p>;
};

VendingMachine.propTypes = {
  imageUrl: PropTypes.string.isRequired,
  loading: PropTypes.bool.isRequired,
  error: PropTypes.bool.isRequired,
  play: PropTypes.func.isRequired,
  clear: PropTypes.func.isRequired
};

export default VendingMachine;

In these components, the code for UI is implemented, and a reusable component renderGif is rendered inside VendingMachine Function with two buttons Play and Clear dispatching asyncFetch() & clearGif() function respectively.

Gif,js

import React from "react";
import PropTypes from "prop-types";
function Gif({ imageUrl }) {
  return (
    <figure>
      <img src={imageUrl} alt="" />
      <figcaption>
        GIFs by <a href="https://giphy.com/">GIPHY</a>
      </figcaption>
    </figure>
  );
};
Gif.propTypes = {
  imageUrl: PropTypes.string.isRequired
};
export default Gif;

Gif components display the gif/image which is passed in the VendeingMachine components through renderGif function on conditional rendering.

actions.js

export const GIF_FETCH_ASYNC = "GIF_FETCH_ASYNC";
export const GIF_FETCH_START = "GIF_FETCH_START";
export const GIF_FETCH_SUCCEED = "GIF_FETCH_SUCCEED";
export const GIF_FETCH_FAILED = "GIF_FETCH_FAILED";
export const GIF_CLEAR = "GIF_CLEAR";

export const gifActions = {
  asyncFetch() {
    return { type: GIF_FETCH_ASYNC };
  },

  startFetch() {
    return { type: GIF_FETCH_START };
  },

  successFetch(payload) {
    return { type: GIF_FETCH_SUCCEED, payload };
  },

  failFetch(payload) {
    return { type: GIF_FETCH_FAILED, payload };
  },

  clearGif() {
    return { type: GIF_CLEAR };
  }
}

This file contains our action creators that return plain JavaScript objects

reducers.js

import { combineReducers } from "redux";
import {
  GIF_FETCH_START,
  GIF_FETCH_SUCCEED,
  GIF_FETCH_FAILED,
  GIF_CLEAR } from "./actions";

const gifInitialState = {
  url: "",loading: false, error: false
};

function gif(state = gifInitialState, action) {
  switch (action.type) {
    case GIF_FETCH_START:
      return Object.assign({}, state, { url: "", loading: true });

    case GIF_FETCH_SUCCEED:
      return Object.assign({}, state, { url: action.payload, loading: false });

    case GIF_FETCH_FAILED:
      console.error(action.payload);
      return Object.assign({}, state, { loading: false, error: true });

    case GIF_CLEAR:
      return { url: "", loading: false, error: false };

    default:
      return state;
  }
}
export default combineReducers({ gif });

The reducer.js file contains a reducer function.

api/gif.js

const API_URL = 'https://ruddy-mail.glitch.me/api/gacha';
export default {
  random() {
    return fetch(API_URL).then(response => response.json());
  }
};

The file contains function that returns promises.  The function makes an API call to get the gif clips and then returns a promise.

sagas.js

import { call, put, takeLatest, fork, all } from "redux-saga/effects";
import { gifActions, GIF_FETCH_ASYNC } from "./actions";
import gifApi from "./api/gif";

function* fetchGif() {
  yield put(gifActions.startFetch());
  try {
    // Invoke asynchronous processing
    const response = yield call(gifApi.random);
    // When the asynchronous processing is completed, the processing is transferred to Redux
    yield put(gifActions.successFetch(response.url));
  } catch (err) {
    yield put(gifActions.failFetch(err));
  }
}

function* watchFetchGif() {
  //Monitor specific actions
  yield takeLatest(GIF_FETCH_ASYNC, fetchGif);
}

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

All async calls and side effects are dealt with in this file. Redux-thunk places the async logic in the action creators, but redux-saga has a separate file for this, which is typically called a saga.

There are typically two different types of generator functions (or sagas) in the saga file:

  • Worker Function.
  • Watcher Function.

A specific action is dispatched to the redux store by the watcher function, which then calls the corresponding worker function to handle any side effects or API calls.

TA function and action are passed as arguments to the takeLatest() effect, which is produced by the watchFetchGif. TakeLatest is a redux-saga helper effect that instructs redux-saga to continuously and concurrently watch for the action of type GIF FETCH ASYNC and execute the fetchGif function, which manages side effects, as soon as it is dispatched. TakeEvery is one of our additional helper effects that are comparable to takeLatest and can be used to listen for actions dispatched.

takeEvery allows multiple fetchData instances to be started concurrently. At a given moment, we can start a new fetchData task while there are still one or more previous fetchData tasks that have not yet terminated. while takeLatest cancel any previous saga.

We now know that the watcher functions call the worker functions, so what happens in them?

In the fetchGif function, it first yields a function call that takes the gifApi.random function we exported from the api/gif.js file.

call is another helper effect provided by redux-saga, it is used to execute/call a function but if that function is a promise it pauses the saga until the promises are resolved. The call effect is like await in async-await syntax.

const response = yield call(gifApi.random);

So what the line above does is call the gifApi.random async function, wait for it to be resolved, then the response is saved in the response variable. if the promise failed then it is caught in the catch block of the try-catch.

if the promise was the successful execution of the saga resumes to the next line:

 yield put(gifActions.successFetch(response.url));

put is another helper effect that is used to dispatch an action to the redux store. The line above dispatches an gifActions.successFetch() action with the response as an argument, so the reducer can update the store state.

If the promise failed then execution continues in the catch block

catch (err) {
    yield put(gifActions.failFetch(err));
  }

Finally, we haven’t covered one more function in the saga file: the rootSaga generator function. This function makes use of another helper effect called all. This effect instructs redux-saga to run the functions concurrently.

The rootSaga function parallelizes the two watcher sagas so that they can be exported and run by the saga middleware. It essentially connects the sagas to the redux-saga middleware.

sagaMiddleware.run(rootSaga);

The line of code above is from the index.js file, which runs the saga.

This is just a simple example to demonstrate how to use redux-saga, but it is clear that there are advantages to using it instead of redux-thunk.

Using Redux-saga has a several advantages.

  • Easier to test since we don’t need to simulate API calls
  • Looks better because callback issues are avoided and asynchronous tasks are completed synchronously.

Conclusion

Redux-saga excels when handling complex asynchronous tasks in Redux, though it may be overkill for the trivial example. Personally, I prefer redux-saga over the promise-based redux-thunk for the majority of my Redux-based projects because it is visually more appealing and simpler to comprehend.

Here is an illustration of the flow:

redux saga

About the author

Praveen Sankadal
By Praveen Sankadal

Category