In this article you will learn the following:
1. How side-effects are handled in Redux
2. What Redux-saga is
3. What generator functions are
4. How to handle side-effects using redux-saga while building a simple application
If you have used Redux in a React project before, there gets a point, where you will have to handle side-effects(asynchronous) like API calls or setting timeout and intervals, because these are required when building features of a “complex web application.”
The Redux architecture is meant to provide predictable state change, meaning it takes an action and state then returns a new state. This architecture doesn’t provide any built-in means for handling asynchronous changes to the Redux state tree.
So where can we put our side-effects in the Redux flow? We could handle our side-effects at the component level then dispatch an action with the resolved result of the side effects.
class App extends React.Component { fetchUser=(id)=>{ Api.fetchUser().then(res =>{ let user = res.user dispatch({type: "USER_FETCH_SUCCEEDED", user: user}) }) .catch(err =>{ dispatch({type: "USER_FETCH_FAILED", message: err.message}) }) } render() { const {id} = this.state return ( <div> <button onClick={()=>this.fetchUser(id)}>Fetch User</button> </div> ); } }
But the problem with this approach is that in large applications, you might have multiple components that want to fetch a user, so you might have duplicate logic, and this becomes difficult to maintain.
Another option is to move the async logic(side-effect) to the action creators. Usually, in redux, action creators are functions that return an action; a plain object that must have a `type` property, at least.
function fetchData(param) { return { type: "FETCH_DATA", payload: param }; }
We can use a middleware like redux-thunk which allows you to dispatch functions/promises and write async logic in your action creators. What the middleware does is look at every action dispatched, if it is a function, it calls that function and pass 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 is passed to it so the store can be read and used in the action creators.
class App extends React.Component { render() { const { id } = this.state; const { dispatch } = this.props; return ( <div> <button onClick={() => dispatch(requestUser(id))}>Fetch User</button> </div> ); } } // action creator(thunk) function requestUser(id) { return function(dispatch) { return Api.fetchUser() .then(res => { let user = res.user; dispatch({ type: "USER_FETCH_SUCCEEDED", user: user }); }) .catch(err => { dispatch({ type: "USER_FETCH_FAILED", message: err.message }); }); }; }
Here, we have the fetch user API call in the action creator, so any component that needs to fetch a user can dispatch the `requestUser()` action instead of duplicating logic.
Using a middleware like redux-thunk has its benefits:
- The business logic is not tightly coupled to the UI components so it can be reused by components.
- It is easy to learn unlike redux-saga(which we will be covering later in this article) because you don’t need to learn new JavaScript concepts to use it.
It also has some disadvantages:
- It can become difficult to read and understand when you start dealing with complex async login in complex applications.
- It doesn’t provide any way to handle advanced async operations like cancellation and debouncing.
Despite its negatives, Redux-thunk remains the most used redux middleware for handling side-effects, and it works for most use-cases, but it doesn’t work in complex applications where you might want to cancel an API request after an action has been dispatched, or you want to debounce a function call.
This complex situation is where a middleware like redux-saga thrive, it helps you handle these easily using special function called effects provided by redux-saga.
Redux-saga also allows us to write async code in a declarative manner hence it is more readable and easier to test. Now we will do a deep dive into Es6 generators and Redux-saga, then we will build a trivial application using redux-saga middleware.
Redux-saga
Redux-saga is built on the concept of generators which was introduced as part of the ECMAScript 2015 module. Generators are a powerful concept, which you might not use every day in programming but its strengths were leveraged to create Redux-saga, which then gives users the ability to write asynchronous code in a synchronous manner, thereby eliminating problems with callbacks(callback hell), as well as helps to run side-effects in parallel, cancel them, etc. Both of which are difficult or impossible using redux-thunk.
What is a Generator function?
A generator function is a function that can be paused and later resumed while retaining its variable bindings(context). It is denoted with an asterisk in front of the function keyword.
function* myFunction() { // // }
Unlike a normal function in JavaScript, which runs until it gets to a `return` statement or until it has completely executed, generators run until it gets to a `yield` or `return` statement.
When a generator function is invoked, it doesn’t execute the function body immediately, instead — it returns an iterable/generator object that can be iterated or looped through using the iterator’s `next()` method or a `for..of loop`.
function* gen() { yield 1; yield 2; yield 3; yield 4; } let myGenerator = gen(); console.log(myGenerator.next()); //{value: 1, done: false} console.log(myGenerator.next()); //{value: 2, done: false} console.log(myGenerator.next()); //{value: 3, done: false} console.log(myGenerator.next()); //{value: 4, done: false} console.log(myGenerator.next()); // {value: undefined, done: true}
Each iteration is defined by the `yield` keyword, so when you execute a generator function and call its `next()` method, it runs the function until it encounters the first `yield` statement, then it pauses and returns an object containing a `value` and `done` property;`value` is anything on the right-hand side of the keyword `yield`, it can be a value(integer, string), function, object, promise, etc., while the `done` property is a `boolean,` which indicates if it is done iterating thorough the iterable.
So how does this fit with redux-saga? In redux-saga, we move our side-effects from the action-creators to generator functions called sagas. What redux-saga does is to run our sagas(generator functions) and provide methods for handling the side-effects and also interacting with the redux store. redux-saga executes our generator functions(sagas) containing the side-effects then handles the process of calling the `next()` on the generator object internally.
Below is a simple saga that makes a cup of coffee after 10 mins:
const delay = ms => new Promise(res => setTimeout(res, ms)); function* makeCoffee() { yield take({ type: "REQUEST_COFFEE" }); yield delay(10000); yield put({ type: "COFFEE_DONE" }); }
This is just a simple snippet of a saga that yields objects to the redux-saga middleware. The yielded objects are like instructions to be interpreted by the middleware. You might have noticed functions `take`, `delay` and `put` in the saga above, these are helper functions provided by redux-saga. We will walk through each line of the saga to understand what it does, then I will show the redux-thunk implementation.
The first line in the example above:
yield take({type:'REQUEST_COFFEE'})
`take` is an effect that instructs the redux-saga middleware to wait for the `REQUEST_COFFEE` action to be dispatched by the store before it resumes execution of the generator function. So this function is paused until someone dispatches a `REQUEST_COFFEE` action; then when it is dispatched, it resumes execution and executes it until the next yield.
yield delay(60000)
`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 60 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 60 seconds, execution resumes until it gets to the final `yield`
yield put({type:'COFFEE_DONE'})
`put` is another effect provided by redux-saga which can be used to dispatch actions in a saga. So this instructs the middleware to dispatch an action `COFFEE_DONE` to the store.
`put` and `take` are examples of helpers effects provided by redux-saga. Effects are plain JavaScript objects which contain specific instructions to be carried out by the middleware.
Effects are divided into two groups in redux-saga, blocking call and non-blocking call.
A blocking call means that the saga yielded an effect and will wait for the outcome of its execution before resuming execution inside the generator function.
A non-blocking call means the saga will resume immediately after yielding the effect.
There are more helper effects, but these are some of them. You can check out the redux-saga docs for all of them.
Demo Application
Now that we have a little knowledge of generators and redux-saga, we will be building a simple application that calls the Jikan API, an unofficial API for MyAnimeList, to get the top animes airing and also search for your favorite anime.
This is what the application will look like
Folder Structure
└───src │ Api.js │ App.js │ App.module.css │ constants.js │ index.css │ index.js │ rootSaga.js │ store.js │ ├───components │ AnimeList.jsx │ AnimeList.module.css │ TopAnimes.jsx │ TopAnimes.module.css │ └───ducks actions.js reducer.js sagas.js types.js
index.js
import React from "react"; import ReactDOM from "react-dom"; import { Provider } from "react-redux"; import store from "./store"; import "./index.css"; import App from "./App"; ReactDOM.render( <Provider store={store}> <App /> </Provider>, document.getElementById("root") );
This is the entry point of our application. In this file, we wrapper the `App` component with the react-redux `Provider,` which gives the `App` component and any component below it in the component tree access to the redux store if it is wrapped using `connect()` function.
store.js
import { applyMiddleware, createStore, compose } from "redux"; import createSagaMiddleware from "redux-saga"; import rootSaga from "./ducks/sagas"; import { animeReducer } from "./ducks/reducer"; const composeEnhancers = (window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ trace: true, traceLimit: 25 })) || compose; const sagaMiddleware = createSagaMiddleware(); const store = createStore( animeReducer, composeEnhancers(applyMiddleware(sagaMiddleware)) ); sagaMiddleware.run(rootSaga); export default store;
In this file, we set up the redux-store, the redux-saga middleware, and the redux-devtools.
const sagaMiddleware = createSagaMiddleware(); ``` The line above creates the saga middleware ```js const store = createStore( animeReducer, composeEnhancers(applyMiddleware(sagaMiddleware)) );
This line creates the store using redux `createStore` function, the first argument passed to it is the root reducer while the second argument is the enhancer, and it composes the redux-devtools and the saga middleware together.
sagaMiddleware.run(rootSaga);
This line above dynamically runs the sagas, and it takes a saga as an argument.
App.js
import React, { useEffect, useState } from "react"; import { connect, useDispatch } from "react-redux"; import AnimeList from "./components/AnimeList"; import TopAnimes from "./components/TopAnimes"; import styles from "./App.module.css"; import { requestTopAnime, requestSearchAnime } from "./ducks/actions"; import { getTopAnimesResults, getSearchResults } from "./ducks/reducer"; function App(props) { const [query, setQuery] = useState(""); const dispatch = useDispatch(); const fetchQuery = () => { dispatch(requestSearchAnime(query)); }; useEffect(() => { dispatch(requestTopAnime()); dispatch(requestSearchAnime()); }, [dispatch]); return ( <> <header className={styles.header}> <div className={styles.logo}>Anime Viewer</div> <nav className={styles.nav}> <input type="text" className={styles.searchField} value={query} onChange={e => setQuery(e.target.value)} /> <button className={styles.searchBtn} onClick={fetchQuery}> Search </button> </nav> </header> <main className={styles.content}> <AnimeList animes={props.animes} /> <TopAnimes topAnimes={props.topAnimes} /> </main> </> ); } const mapStateToProps = state => ({ topAnimes: getTopAnimesResults(state), animes: getSearchResults(state) }); export default connect(mapStateToProps, null)(App);
This is the parent component of our application, when it is about to mount it dispatches two actions to the redux store; `requestTopAnime` which requests the top airing animes and `requestSearchAnime()` which requests the top animes of all time. `requetSearchAnime()` is also used to search for animes if a query argument is passed to it.
This component has a search field so you can search for your favorite anime and two other components `AnimeList` and `TopAnimes`.
AnimeList.js
const AnimeList = props => { return ( <section className={styles.listContainer}> {props.animes.map(anime => { return ( <div key={anime.mal_id} className={styles.anime}> <img src={anime.image_url} alt={anime.title} /> <div className={styles.overlay}> <p className={styles.title}>{anime.title}</p> <p className={styles.rating}> <span>Score:</span> {anime.score} </p> </div> </div> ); })} </section> ); }; const mapStateToProps = state => ({ animes: getSearchResults(state) }); export default connect(mapStateToProps)(AnimeList);
This component displays the search results if there is a query, or the best animes if the query field is empty. We connect this component to the Redux store using react-redux `connect()` function. Also, we have a state selector `getSearchResults` that is used to select the part of the state needed in this component.
TopAnimes.js
const TopAnimes = props => { return ( <aside> <p>Top airing anime</p> <div className={styles.rankContainer}> {props.topAnimes.slice(0, 20).map(item => { return ( <div className={styles.rankItem} key={item.mal_id}> <img src={item.image_url} alt={item.title} className={styles.rankImage} height="70" width="50" /> <div className={styles.rankDetails}> <p>{item.title}</p> <p>Episode: {item.episodes}</p> </div> </div> ); })} </div> </aside> ); }; const mapStateToProps = state => ({ topAnimes: getTopAnimesResults(state) }); export default connect(mapStateToProps)(TopAnimes);
It is similar to the `AnimeList` component, but it renders the lists of top animes airing. It has a state selector `getTopAnimesResults` for selecting the relevant state it needs.
actions.js
import * as types from "./types"; export const requestSearchAnime = payload => ({ type: types.SEARCH_REQUEST, payload: payload }); export const fulfilledSearchAnime = payload => ({ type: types.SEARCH_SUCCESS, payload: payload }); export const failedSearchAnime = payload => ({ type: types.SEARCH_FAILURE, payload: payload }); export const requestTopAnime = payload => ({ type: types.TOPANIME_REQUEST, payload: payload }); export const fulfilledTopAnime = payload => ({ type: types.TOPANIME_SUCCESS, payload: payload }); export const failedTopAnime = payload => ({ type: types.TOPANIME_FAILURE, payload: payload });
This file contains our action creators that returns plain JavaScript objects.
Reducer.js
import { combineReducers } from "redux"; import * as types from "./types"; export const initialState = {}; const searchResults = (state = [], { type, payload }) => { switch (type) { case types.SEARCH_SUCCESS: return [...payload]; case types.SEARCH_FAILURE: return state; default: return state; } }; const topAnimes = (state = [], { type, payload }) => { switch (type) { case types.TOPANIME_SUCCESS: return [...payload]; case types.TOPANIME_FAILURE: return state; default: return state; } }; // State Selectors export const getSearchResults = state => state.searchResults; export const getTopAnimesResults = state => state.topAnimes; export const animeReducer = combineReducers({ searchResults, topAnimes });
The reducer.js file contains two reducer:
- `searchResults`: This is where the state of the search results is managed
- `loadingSearchResults`: This is used to manage the loading state of the top airing animes.
Api.js
import axios from "axios"; import { baseURL } from "./constants"; export async function fetchTopAnimes() { return axios.get(`${baseURL}/top/anime/1/airing`); } export async function fetchAllAnimes(query) { if (query) { return axios.get(`${baseURL}/search/anime/?q=${query}&page=1`); } else { return axios.get(`${baseURL}/search/anime/?order_by=score&page=1`); } }
The file contains two functions that return promises.
- `fetchTopAnimes`: This function makes an API call to get the top airing animes and then returns a promise.
- `fetchAllAnimes`: This function makes an API call to get the results of the search query if there is a query, or it gets the best animes of all time if there is no query string. It also returns a promise.
saga.js
import { call, put, takeEvery, all } from "redux-saga/effects"; import { fetchTopAnimes, fetchAllAnimes } from "../Api"; import { SEARCH_REQUEST, TOPANIME_REQUEST } from "./types"; import { fulfilledTopAnime, failedTopAnime, fulfilledSearchAnime, failedSearchAnime } from "./actions"; function* getTopAnimesWorker(action) { try { const data = yield call(fetchTopAnimes); let res = data.data.top; yield put(fulfilledTopAnime(res)); } catch (err) { yield put(failedTopAnime(err)); } } function* getSearchAnimesWorker(action) { try { const data = yield call(fetchAllAnimes, action.payload); let res = data.data.results; yield put(fulfilledSearchAnime(res)); } catch (err) { yield put(failedSearchAnime(err)); } } function* getTopAnimeWatcher() { yield takeEvery(TOPANIME_REQUEST, getTopAnimesWorker); } function* getSearchAnimesWatcher() { yield takeEvery(SEARCH_REQUEST, getSearchAnimesWorker); } export default function* rootSaga() { yield all([getTopAnimeWatcher(), getSearchAnimesWatcher()]); }
This is the file where all the async calls/side-effects are being handled. In redux-thunk the async logic is put in the action creators, but in redux-saga, we have a dedicated file for this usually called sagas.
The saga file is usually structured in a way that we have two types of generator functions(sagas):
- Worker Function
- Watcher Function
The Watcher function waits/watches for a specific action to be dispatched to the redux store, then it calls the respective worker function which handles the side-effects/API calls
The two watcher functions are:
- getTopAnimeWatcher
- getSearchAnimesWatcher
The `getTopAnimeWatcher` yields an effect called `takeEvery()`, which takes an action and a function as arguments. `takeEvery` is a redux-saga helper effect that tells redux-saga to continuously and concurrently wait for an action of type `TOPANIME_REQUEST` to be dispatched, immediately it is dispatched, it should execute the `getTopAnimesWorker` function which handles the side-effects. We have another similar helper effects to `takeEvery` that can be used to listen for actions dispatched called `takeLatest`.
`takeLatest` calls its worker saga every time an action’s dispatched and it automatically cancels any previous execution of the worker saga if it is still running, while `takeEvery` does not cancel any previous saga.
The `getSearchAnimesWatcher` is similar to the `getTopAnimeWatcher`. When an action of type `SEARCH_REQUEST` is dispatched, it calls the `getSearchAnimesWorker` function.
We now know that the watcher functions call the worker functions, so what happens in them?
In the `getTopAnimesWorker` function, it first yields a function `call` that takes the `fetchTopAnimes` function we exported from the `Api.js` file.
`call` is another helper effects 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 data = yield call(fetchTopAnimes);
So what the line above does is call the `fetchTopAnimes` async function, wait for it to be resolved, then the response is saved in the `data` variable. if the promise failed then it is caught in the catch block of the `try-catch`.
if the promise was successful execution of the saga resumes to the next line:
yield put(fulfilledTopAnime(res));
`put` is another helper effect which is used to dispatch an action to the redux store. The line above dispatches a `fulfilledTopAnime()` 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(e){ yield put(failedTopAnime(err)); }
This dispatches an action to the store to indicate the request failed.
`getSearchAnimesWorker` is similar to the `getTopAnimesWorker` function, the only difference is that the `call` effect takes a second argument.
const data = yield call(fetchAllAnimes, action.payload);
This second argument makes it possible to pass an argument to the `fetchAllAnimes` function, so it looks something like this when it is called
call(fetchAllAnimes(action.payload));
Apart from that, everything else is the same as the `getTopAnimesWorker` we talked about earlier.
Finally, there is one more function in the saga file we have not discussed yet; the rootSaga generator function. This function uses another helper effect `all`. This effect tells redux-saga to run the functions in parallel.
The rootSaga function runs the two watcher sagas in parallel, so it can be exported and ran by the saga middleware. Basically, it connects the sagas to the redux-saga middleware
sagaMiddleware.run(rootSaga);
Above is a line of code in the `store.js` file that runs the saga.
This is just a trivial example to show you how to use redux-saga, but it is obvious there are benefits of using it instead of redux-thunk.
Some benefits of using Redux-saga:
- Easier to test because we don’t have to mock API calls
- Looks cleaner because we don’t have to deal with callbacks and asynchronous tasks are achieved in a synchronous nature.
Conclusion
Redux-saga might be an overkill for the trivial example, but it thrives when you need to handle complex asynchronous tasks in Redux. Personally, I use redux-saga for most of my Redux-based projects, because it looks a lot cleaner and easier to understand than the promise-based redux-thunk.
Github: https://github.com/nero2009/Anime-viewer
Here is an illustration of the flow:
Try Redux Observables