Eat This, It’s Safe: How to Manage Side Effects with Redux-Saga

by Hunter MacDermut

Functional programming is all the rage, and for good reason. By introducing type systems, immutable values, and enforcing purity in our functions, to name just a few advantages, we can reduce the complexity of our code while bolstering our confidence that it will run with minimal errors. It was only a matter of time before these concepts crept their way into the increasingly sophisticated front-end technologies that power the web.

Projects like ClojureScript, Reason, and Elm seek to fulfill the promise of a more-functional web by allowing us to write our applications with functional programming restraints that compile down to regular ol’ JavaScript for use in the browser. Learning a new syntax and having to rely on a less-mature package ecosystem, however, are a couple roadblocks for many who might be interested in using compile-to-JS languages. Fortunately, great strides have been made in creating libraries to introduce powerful functional programming tenets directly into JavaScript codebases with a gentler learning curve.

One such library is Redux, which is a state-management tool heavily inspired by the aforementioned Elm programming language. Redux allows you to create a single store that holds the state of your entire app, rather than managing that state at the component level. This store is globally-available, allowing you to access the pieces of it that you need in whichever components need them without worrying about the shape of your component tree. The process of updating the store involves passing the store object and a descriptive string, called an action, into a special function called a reducer. This function then creates and returns a new store object with the changes described by the action.

This process is very reliable. We can be sure that the store will be updated in exactly the same way every single time so long as we pass the same action to the reducer. This predictable nature is critical in functional programming. But there’s a problem: what if we want our action to fire-off an API call? We can’t be sure what that call will return or that it’ll even succeed. This is known as a side effect and it’s a big no-no in the FP world. Thankfully, there’s a nice solution for managing these side effects in a predictable way: Redux-Saga. In this article, we’ll take a deeper look at the various problems one might run into while building their Redux-powered app and how Redux-Saga can help mitigate them.

Prerequisites

In this article, we’ll be building an application to store a list of monthly bills. We’ll focus specifically on the part that handles fetching the bills from a remote server. The pattern we’ll look at works just the same with POST requests. We’ll bootstrap this app with create-react-app, which will cover most of the code I don’t explicitly walkthrough.

What is Redux-Saga?

Redux-Saga is a Redux middleware, which means it has access to your app’s store and can dispatch its own actions. Similar to regular reducers, sagas are functions that listen for dispatched actions. Additionally, they perform side effects and return their own actions back to a normal reducer.

Redux Flow with Saga Middleware

By intercepting actions that cause side effects and handling them in their own way, we maintain the purity of Redux reducers. This implementation uses JS generators, which allows us to write asynchronous code that reads like synchronous code. We don’t need to worry about callbacks or race conditions since the generator function will automatically pause on each yield statement until complete before continuing. This improves the overall readability of our code. Let’s take a look at what a saga for loading bills from an API would look like.

 1   import { put, call, takeLatest } from 'redux-saga/effects';
 2   
 3   export function callAPI(method = 'GET', body) {
 4     const options = {
 5       headers,
 6       method
 7     }
 8   
 9     if (body !== undefined) {
10       options.body = body;
11     }
12   
13     return fetch(apiEndpoint, options)
14             .then(res => res.json())
15             .catch(err => { throw new Error(err.statusText) });
16   }
17   
18   export function* loadBills() {
19     try {
20       const bills = yield call(callAPI);
21       yield put({ type: 'LOAD_BILLS_SUCCESS', payload: bills });
22     } catch (error) {
23       yield put({ type: 'LOAD_BILLS_FAILURE', payload: error });
24     }
25   }
26   
27   export function* loadBillsSaga() {
28     yield takeLatest('LOAD_BILLS', loadBills);
29   }

Let’s tackle it line-by-line:

  • Line 1: We import several methods from redux-saga/effects. We’ll use takeLatest to listen for the action that kicks-off our fetch operation, call to perform said fetch operation, and put to fire the action back to our reducer upon either success or failure.
  • Line 3-16: We’ve got a helper function that handles the calls to the server using the fetch API.
  • Line 18: Here, we’re using a generator function, as denoted by the asterisk next to the function keyword.
  • Line 19: Inside, we’re using a try/catch to first try the API call and catch if there’s an error. This generator function will run until it encounters the first yield statement, then it will pause execution and yield out a value.
  • Line 20: Our first yield is our API call, which, appropriately, uses the call method. Though this is an asynchronous operation, since we’re using the yield keyword, we effectively wait until it’s complete before moving on.
  • Line 21: Once it’s done, we move on to the next yield, which makes use of the put method to send a new action to our reducer. Its type describes it as a successful fetch and contains a payload of the data fetched.
  • Line 23: If there’s an error with our API call, we’ll hit the catch block and instead fire a failure action. Whatever happens, we’ve ended up kicking the ball back to our reducer with plain JS objects. This is what allows us to maintain purity in our Redux reducer. Our reducer doesn't get involved with side effects. It continues to care only about simple JS objects describing state changes.
  • Line 27: Another generator function, which includes the takeLatest method. This method will listen for our LOAD_BILLS action and call our loadBills() function. If the LOAD_BILLS action fires again before the first operation completed, the first one will be canceled and replaced with the new one. If you don’t require this canceling behavior, redux-saga/effects offer the takeEvery method.

One way to look at this is that saga functions are a sort-of intercepting reducer for certain actions. We fire-off the LOAD_BILLS action, Redux-Saga intercepts that action (which would normally go straight to our reducer), our API call is made and either succeeds or fails, and finally, we dispatch an action to our reducer that handles the app’s state update. Oh, but how is Redux-Saga able to intercept Redux action calls? Let’s take a look at index.js to find out.

 1   import React from 'react';
 2   import ReactDOM from 'react-dom';
 3   import App from './App';
 4   import registerServiceWorker from './registerServiceWorker';
 5   import { Provider } from 'react-redux';
 6   import { createStore, applyMiddleware } from 'redux';
 7   import billsReducer from './reducers';
 8   
 9   import createSagaMiddleware from 'redux-saga';
10   import { loadBillsSaga } from './loadBillsSaga';
11   
12   const sagaMiddleware = createSagaMiddleware();
13   const store = createStore(
14     billsReducer,
15     applyMiddleware(sagaMiddleware)
16   );
17   
18   sagaMiddleware.run(loadBillsSaga);
19   
20   ReactDOM.render(
21     <Provider store={store}>
22       <App />
23     </Provider>,
24     document.getElementById('root')
25   );
26   registerServiceWorker();

The majority of this code is standard React/Redux stuff. Let’s go over what’s unique to Redux-Saga.

  • Line 6: Import applyMiddleware from redux. This will allow us to declare that actions should be intercepted by our sagas before being sent to our reducers.
  • Line 9: createSagaMiddleware from Redux-Saga will allow us to run our sagas.
  • Line 12: Create the middleware.
  • Line 15: Make use of Redux’s applyMiddleware to hook our saga middleware into the Redux store.
  • Line 18: Initialize the saga we imported. Remember that sagas are generator functions, which need to be called once before values can be yielded from them.

At this point, our sagas are running, meaning they’re waiting to respond to dispatched actions just like our reducers are. Which brings us to the last piece of the puzzle: we have to actually fire off the LOAD_BILLS action! Here’s the BillsList component:

 1   import React, { Component } from 'react';
 2   import Bill from './Bill';
 3   import { connect } from 'react-redux';
 4   
 5   class BillsList extends Component {
 6     componentDidMount() {
 7       this.props.dispatch({ type: 'LOAD_BILLS' });
 8     }
 9   
10     render() {
11       return (
12         <div className="BillsList">
13           {this.props.bills.length && this.props.bills.map((bill, i) =>
14             <Bill key={`bill-${i}`} bill={bill} />
15           )}
16         </div>
17       );
18     }
19   }
20   
21   const mapStateToProps = state => ({
22     bills: state.bills,
23     error: state.error
24   });
25   
26   export default connect(mapStateToProps)(BillsList);

I want to attempt to load the bills from the server once the BillsList component has mounted. Inside componentDidMount we fire off LOAD_BILLS using the dispatch method from Redux. We don’t need to import that method since it’s automatically available on all connected components. And this completes our example! Let’s break down the steps:

  1. BillsList component mounts, dispatching the LOAD_BILLS action
  2. loadBillsSaga responds to this action, calls loadBills
  3. loadBills calls the API to fetch the bills
  4. If successful, loadBills dispatches the LOAD_BILLS_SUCCESS action
  5. billsReducer responds to this action, updates the store
  6. Once the store is updated, BillsList re-renders with the list of bills

Testing

A nice benefit of using Redux-Saga and generator functions is that our async code becomes less-complicated to test. We don’t need to worry about mocking API services since all we care about are the action objects that our sagas output. Let’s take a look at some tests for our loadBills saga:

 1   import { put, call } from 'redux-saga/effects';
 2   import { callAPI, loadBills } from './loadBillsSaga';
 3   
 4   describe('loadBills saga tests', () => {
 5     const gen = loadBills();
 6     
 7     it('should call the API', () => {
 8       expect(gen.next().value).toEqual(call(callAPI));
 9     });
10   
11     it('should dispatch a LOAD_BILLS_SUCCESS action if successful', () => {
12       const bills = [
13         {
14           id: 0,
15           amountDue: 1000,
16           autoPay: false,
17           dateDue: 1,
18           description: "Bill 0",
19           payee: "Payee 0",
20           paid: true
21         },
22         {
23           id: 1,
24           amountDue: 1001,
25           autoPay: true,
26           dateDue: 2,
27           description: "Bill 1",
28           payee: "Payee 1",
29           paid: false
30         },
31         {
32           id: 2,
33           amountDue: 1002,
34           autoPay: false,
35           dateDue: 3,
36           description: "Bill 2",
37           payee: "Payee 2",
38           paid: true
39         }
40       ];
41       expect(gen.next(bills).value).toEqual(put({ type: 'LOAD_BILLS_SUCCESS', payload: bills }));
42     });
43   
44     it('should dispatch a LOAD_BILLS_FAILURE action if unsuccessful', () => {
45       expect(gen.throw({ error: 'Something went wrong!' }).value).toEqual(put({ type: 'LOAD_BILLS_FAILURE', payload: { error: 'Something went wrong!' } }));
46     });
47   
48     it('should be done', () => {
49       expect(gen.next().done).toEqual(true);
50     });
51   });

Here we’re making use of Jest, which create-react-app provides and configures for us. This makes things like describe, it, and expect available without any importing required. Taking a look at what this saga is doing, I’ve identified 4 things I’d like to test:

  • The saga fires off the request to the server
  • If the request succeeds, a success action with a payload of an array of bills is returned
  • If the request fails, a failure action with a payload of an error is returned
  • The saga returns a done status when complete

By leveraging the put and call methods from Redux-Saga, I don’t need to worry about mocking the API. The call method does not actually execute the function, rather it describes what we want to happen. This should seem familiar since it’s exactly what Redux does. Redux actions don’t actually do anything themselves. They’re just JavaScript objects describing the change. Redux-Saga operates on this same idea, which makes testing more straightforward. We just want to assert that the API was called and that we got the appropriate Redux action back, along with any expected payload.

  • Line 5: first we need to initialize the saga (aka run the generator function). Once it’s running we can start to yield values out of it. The first test, then, is simple.
  • Line 8: call the next method of the generator and access its value. Since we used the call method from Redux-Saga instead of calling the API directly, this will look something like this:
{ '@@redux-saga/IO': true, CALL: { context: null, fn: [Function: callAPI], args: [] } }

This is telling us that we’re planning to fire-off the callAPI function as we described in our saga. We then compare this to passing callAPI directly into the call method and we should get the same descriptor object each time.

  • Line 11: Next we want to test that, given a successful response from the API, we return a new action with a payload of the bills we retrieved. Remember that this action will then be sent to our Redux reducer to handle updating the app state.
  • Line 12-40: Start by creating some dummy bills we can pass into our generator.
  • Line 41: Perform the assertion. Again we call the next method of our generator, but this time we pass-in the bills array we created. This means that when our generator reaches the next yield keyword, this argument will be available to it. We then compare the value after calling next to a call using the put method from Redux-Saga with the action.
  • Line 44-46: When testing the failure case, instead of plainly calling the next method on our generator, we instead use the throw method, passing in an error message. This will cause the saga to enter its catch block, where we expect to find an action with the error message as its payload. Thus, we make that assertion.
  • Line 48-50: Finally, we want to test that we’ve covered all the yield statements by asserting that the generator has no values left to return. When a generator has done its job, it will return an object with a done property set to true. If that’s the case, our tests for this saga are complete!

Conclusion

We’ve achieved several objectively useful things by incorporating Redux-Saga into our project:

  • Our async code has a more synchronous look to it thanks to the use of generators
  • Our Redux reducers remain pure (no side effects)
  • Our async code is simpler to test

I hope this article has given you enough information to understand how Redux-Saga works and what problems it solves, and made a case for why you should consider using it.

Further Reading

Header photo by Becky Matsubara

newsletter-bot