Managing Side Effects With Redux Saga: A Primer

Introduction

This is the first post in a three-part series on Redux Saga and how beneficial it is in Redux applications for managing side effects.

One difficult problem in building front-end applications is how to manage and test side effects. As developers, we strive to write bug-free code that is easy to test and reason about. Unfortunately, side effects make that challenging for multiple reasons. Side effects introduce nondeterminism, degrading our confidence in knowing all the possible runs of our program. Side effects introduce impurity, creating unintended consequences and potential data races. Finally, side effects are just plain hard and annoying to test.

So, what are side effects and why are they such troublemakers? Wikipedia states that "a function or expression is said to have a side effect if it modifies some state or has an observable interaction with calling functions or the outside world" [1]. For a moment, think of side effects in terms of medicine. A doctor might prescribe a specific medication for a particular ailment. Even though that medication has a specific purpose, it will more than likely have some unintended side effects like an allergic reaction. This is not too dissimilar from some of the functions and methods we write in code. Functions solve a specific problem but may have side effects.

Take the below code sample for example. We have an add function that computes the sum of its arguments but also logs the result to the console as well as incrementing a counter on the server.

function add(x, y) {
  const result = x + y;

  console.log(`${x} + ${y} = ${result}`);
  fetch('/increment-add-calls', { method: 'post' });

  return result;
}

We call this function impure because it has side effects. The first side effect is that it is mutating global state by writing to the console. The second side effect is that it is calling a REST API. These side effects make the function impure and nondeterministic because multiple calls to the function with the same arguments will never have the same outcome. What if the API call fails? What if the fetch function [2] is not available? What if a global console object is not available? Even though the function will always return the sum of its arguments, these side effects cause it to have an "observable interaction with... the outside world" [1]. One can only imagine the ramifications of more complex examples and real-world code that have side effects such as these.

OK, that's nice, but do side effects really matter? Shouldn't we just be pragmatic and ship the code as soon as possible? Sure, right after we write some tests.

test('computes the sum of its arguments', () => {
  expect(add(2, 2)).toBe(4);
  expect(add(40, 2)).toBe(42);
});

test('logs its output to the console', () => {
  const spy = sinon.spy(console, 'log');

  add(2, 2);
  add(40, 2);

  expect(spy.calledWith('2 + 2 = 4')).toBe(true);
  expect(spy.calledWith('40 + 2 = 42')).toBe(true);

  spy.restore();
});

test('increments the number of calls on the server', () => {
  const server = sinon.fakeServer.create();

  server.respondWith('POST', '/increment-add-calls', [
    200,
    { 'Content-Type': 'application/json' },
    JSON.stringify({ success: true })
  ]);

  add(2, 2);
  server.respond();

  add(40, 2);
  server.respond();

  expect(requests[0].url).toBe('/increment-add-calls');
  expect(requests[0].method).toBe('POST');

  expect(requests[1].url).toBe('/increment-add-calls');
  expect(requests[1].method).toBe('POST');

  server.restore();
});

Yikes! That's a lot of ceremony just to test a simple function. What happens when we add more functions with API calls and other side effects? Our test files are going to quickly balloon in size and complexity.

Clearly, this is untenable. At least for me, I am more likely to not write tests if that's the type of code I have to constantly test.

So what do we do? We can't really remove side effects. They are unavoidable. What good is an application that can never make API calls? Therefore, what if instead of letting side effects run wild in our applications, we controlled their execution, enabling us to write purer code that can still interact with the outside world...

Redux Saga

In the world of React and Redux, this is a definite possibility. Enter Redux Saga, a self-billed "alternative side effect model for Redux apps" [3]. Redux Saga builds upon ES2015 generator functions [4] along with declarative side-effect descriptors to allow the creation of more deterministic functions called sagas. Instead of directly calling code that causes side effects like fetch, sagas describe the side effects themselves without actually running them. So what would our earlier add function example look like as a saga?

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

function* add(x, y) {
  const result = x + y;

  yield call(fetch, '/increment-add-calls', { method: 'post' });
  yield call([console, console.log], `${x} + ${y} = ${result}`);

  yield put({ type: 'ADD', payload: result });
}

So what in the world is happening in this code? Let's break it down line-by-line.

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

First, we need to import our descriptors. Descriptors are also known as effect creators. We import descriptors from redux-saga/effects.


function* add(x, y) {

This is a generator function, so we modify it by placing the star * between the function keyword and the identifier add.


const result = x + y;

We perform the normal addition as before, so there is nothing novel or new here.


yield call(fetch, '/increment-add-calls', { method: 'post' });

Now we're doing something completely different here. We're using the yield keyword to yield a call descriptor, passing in the fetch function and its arguments. Contrast this with the first example.

fetch('/increment-add-calls', {method: 'post});

See the difference. Before, we were actually invoking fetch with its arguments. Now, we're yielding a call descriptor of fetch and its arguments. Inside our saga, fetch is never called. Instead, by yielding call, our saga instructs Redux Saga to manage the actual invocation of fetch. (We'll see a little later how Redux Saga does this as we review its architecture.)

Similarly on the next line, the saga yields a call descriptor for console.log without actually invoking console.log itself.

yield call([console, console.log], `${x} + ${y} = ${result}`);

Notice that we don't directly pass in console.log as the first argument but instead an array with console and console.log as elements. This is mainly to deal with this binding issues in JavaScript. Yielding a call descriptor with an array as the first argument lets Redux Saga know to invoke console.log with console as the receiver.


yield put({ type: 'ADD', payload: result });

Finally, instead of returning the result, we use a put descriptor. This is mainly a Redux idiom. Since we want this saga to be integrated with Redux, we'll assume the result is stored somewhere in the store state. Normally, to update the Redux store, we dispatch actions to the store via the dispatch function from the created store. Because updating the store is also a side effect, sagas can elect to update in a declarative manner with a put descriptor. The put descriptor takes a Redux action as an argument. When the saga yields a put descriptor, Redux Saga will know to dispatch the action on behalf of the saga.

So what's the point of yielding these descriptors? Why not just call fetch, console.log, and dispatch directly? Remember the issues we had earlier with testing? We will find in a bit why testing this saga can be immensely easier than our previous testing approach thanks to yielded descriptors. For now, let's step back and examine Redux Saga's architecture and role at a high level.

Redux Saga Architecture

Redux Saga is a lot like a mediator between your application code and the outside world. I already hinted at that when we looked at yielding descriptors from a saga. Instead of interacting with the outside world directly, we let Redux Saga know how we want to interact with the outside world. This allows us to focus our coding and testing efforts on only our business logic and not how our application is wired up to the rest of the world. This concept is not new and was appropriately named by Gary Bernhardt as "functional core, imperative shell" in one of his screencasts as well as his talk entitled "Boundaries" [5]. Just as Gary suggests using values as boundaries, we use descriptors as boundaries between our application and the outside world.

For a high level overview of how Redux Saga fits into a Redux application, please refer to the diagram below.

Figure 1: Redux Saga Architecture


Notice how Redux Saga sits between your sagas and the rest of the world. In order for the sagas to communicate with the Redux Store, APIs, the console, and even each other, they have to first talk with Redux Saga itself. Redux Saga mediates the conversation so that sagas can stay relatively pure and easy to test.

Simple Example

So what are some more practical uses of Redux Saga? I've already shown that it can work with APIs. How would we leverage that in a real application? Let's look at a simple example.

function fetchJson(url) {
  return fetch(url)
    .then(response => {
      if (!response.ok) {
        const error = new Error(response.statusText);
        error.response = response;
        throw error;
      }

      return response.json();
    });
}

function fetchHotNewMemes() {
  return fetchJson('/hot-new-memes.json');
}

function* mainSaga() {
  try {
    const memes = yield call(fetchHotNewMemes);

    yield put({
      type: 'ADD_MEMES',
      payload: memes,
    });
  } catch (e) {
    yield put({
      type: 'FETCH_MEMES_ERROR',
      payload: e,
      error: true,
    });
  }
}

In this example, we have a couple utility functions called fetchJson and fetchHotNewMemes (because we're down with our Fellow Kids™). fetchJson just wraps over the fetch API, returning a Promise that will fulfill the response as a JSON payload or throw an error if the HTTP response code wasn't in the normal OK range of 200-299. fetchHotNewMemes is just a convenience function for calling fetchJson with the URL for our hot new memes. We'll assume that our API endpoint returns a JSON array of memes.

Next, we have our saga function called mainSaga. Notice that it's using our call and put descriptors again, but it's actually getting a value back from the yielded call descriptor this time.

const memes = yield call(fetchHotNewMemes);

One nice property of Redux Saga is that if you yield a call to a function that returns a Promise, then Redux Saga will wait on the Promise to fulfill and return the fulfilled value back inside your saga. Therefore, not only can our sagas yield descriptors to Redux Saga, but Redux Saga can return values back.

This also allows us to write asynchronous code in a synchronous-like manner. Notice how we don't have to deal with the actual Promise inside our code. There is no tangled web of callbacks or .then callback chains. We can treat our call to fetchHotNewMemes like a blocking RPC (remote procedure call). This simplifies our code and makes it more readable.

The next line is similar to what we've seen before. Once we've retrieved the array of memes, we can dispatch to the store to append the memes to our list via yielding the put descriptor with an ADD_MEMES action.

    yield put({
      type: 'ADD_MEMES',
      payload: memes,
    });

Now let's back up. Notice something else? We wrapped the call and put descriptors with a try block.

  try {
    const memes = yield call(fetchHotNewMemes);

    yield put({
      type: 'ADD_MEMES',
      payload: memes,
    });

In addition to handling the fulfilled Promise value from our API call, Redux Saga can also handle Promise rejections (i.e. if the API call failed). If the Promise rejects, then internally Redux Saga will call the generator's throw method with the rejection error. When this happens, the try will fail and execution will jump down to the catch block. Because the API call failed, we will instead dispatch a FETCH_MEMES_ERRORS action with the error as the payload.

  } catch (e) {
    yield put({
      type: 'FETCH_MEMES_ERROR',
      payload: e,
      error: true,
    });
  }

Testing

So I mentioned testing being easier. How would we actually test the previous examples we've seen? Let's revisit our first contrived add function.

import { test, expect } from 'some-testing-lib';
import { call, put } from 'redux-saga/effects';

function* add(x, y) {
  const result = x + y;

  yield call(fetch, '/increment-add-calls', { method: 'post' });
  yield call([console, console.log], `${x} + ${y} = ${result}`);

  yield put({ type: 'ADD', payload: result });
}

test('add', () => {
  const saga = add();

  let result = saga.next();

  expect(result.value).toEqual(
    call(fetch, '/increment-add-calls', { method: 'post' })
  );

  result = saga.next();

  expect(result.value).toEqual(
    call([console, console.log], `${x} + ${y} = ${result}`)
  );

  result = saga.next();

  expect(result.value).toEqual(
    put({ type: 'ADD', payload: result })
  );

  result = saga.next();

  expect(result.done).toBe(true);
});

Wow! That's a lot simpler to test. No spies, no fake servers, just values. So what's going on?

Since add is just a generator function, we can invoke it and get back an iterator. Then, we can step through every yield by calling the next method of the iterator. Because the generator only yields descriptors, then we can just compare the yielded value with the expected descriptor.

  expect(result.value).toEqual(
    call(fetch, '/increment-add-calls', { method: 'post' })
  );

Granted, this way of testing may seem a little confusing and complex at first, but it beats mocking and more closely resembles simple units tests of pure functions. We can think of every yield as the return value of a simple function. That's easy to test. Mocking out a ton of dependencies like servers is not.

For completeness, let's write a test for our other saga.

import { test, expect } from 'some-testing-lib';
import { call, put } from 'redux-saga/effects';

function fetchJson(url) {
  return fetch(url)
    .then(response => {
      if (!response.ok) {
        const error = new Error(response.statusText);
        error.response = response;
        throw error;
      }

      return response.json();
    });
}

function fetchHotNewMemes() {
  return fetchJson('/hot-new-memes.json');
}

function* mainSaga() {
  try {
    const memes = yield call(fetchHotNewMemes);

    yield put({
      type: 'ADD_MEMES',
      payload: memes,
    });
  } catch (e) {
    yield put({
      type: 'FETCH_MEMES_ERROR',
      payload: e,
      error: true,
    });
  }
}

test('mainSaga fetches memes', () => {
  const saga = mainSaga();

  let result = saga.next();

  expect(result.value).toEqual(call(fetchHotNewMemes))

  const memes = [
    'Doge',
    'Actual Advice Mallard',
    'Pun Dog',
  ];

  result = saga.next(memes);

  expect(result.value).toEqual(
    put({
      type: 'ADD_MEMES',
      payload: memes,
    })
  );

  result = saga.next();

  expect(result.done).toBe(true);
});

test('mainSaga handles errors', () => {
  const saga = mainSaga();

  let result = saga.next();

  expect(result.value).toEqual(call(fetchHotNewMemes))

  const error = new Error('No Memes');

  result = saga.throw(error);

  expect(result.value).toEqual(
    put({
      type: 'FETCH_MEMES_ERROR',
      payload: error,
      error: true,
    })
  );

  result = saga.next();

  expect(result.done).toBe(true);
});

This example looks very similar to the previous one. However, notice we are passing values back into the saga now.

    const memes = [
    'Doge',
    'Actual Advice Mallard',
    'Pun Dog',
  ];

  result = saga.next(memes);

  expect(result.value).toEqual(
    put({
      type: 'ADD_MEMES',
      payload: memes,
    })
  );

We pass in a fake array of memes and check that we get back a yielded action with the type ADD_MEMES and the memes array as the payload. This resembles a unit test of a pure function even better. Data in, data out. We don't need to create a server to send down a fake array of memes; just pass in the actual array to the iterator's next method for the saga to consume.

Finally, we also test that our saga handles error properly by calling the throw method instead of passing in an array of memes.

  const error = new Error('No Memes');

  result = saga.throw(error);

  expect(result.value).toEqual(
    put({
      type: 'FETCH_MEMES_ERROR',
      payload: error,
      error: true,
    })
  );

Getting Started

Installation

I'll assume you're already using Redux in your application, which means you're more than likely using npm or yarn as a front-end package manager. If you're not using either, you can find UMD bundles of Redux Saga by following instructions in the GitHub repo.

To install Redux Saga via npm just run this from the command line in the root of your application.

npm install --save redux-saga

If you’re using the yarn instead, then you can run:

yarn add redux-saga --dev

Usage

Redux Saga works by tapping into the middleware architecture of Redux, so you'll need to integrate Redux Saga's middleware when you create your store. A minimal example is below.

import { applyMiddleware, createStore } from 'redux';
import createSagaMiddleware from 'redux-saga';
import reducer from './reducer';
import mainSaga from './sagas';

const sagaMiddleware = createSagaMiddleware();

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

sagaMiddleware.run(sagaMiddleware);

We first import the createSagaMiddleware function which is the default export from redux-saga.

import createSagaMiddleware from 'redux-saga';

Next, we invoke createSagaMiddleware to create a new instance of the Redux Saga middleware.

const sagaMiddleware = createSagaMiddleware();

Then, we create the store via createStore, utilizing the applyMiddleware enhancer from Redux to add in the instance of the Redux Saga middleware.

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

sagaMiddleware.run(sagaMiddleware);

Finally, once the store is created and the Redux Saga middleware is hooked up, we can start our saga.

sagaMiddleware.run(sagaMiddleware);

Conclusion

As this primer has shown, Redux Saga is a powerful tool for managing side effects. We can decouple our business logic from the side effects of interacting with the outside world by treating those side effects as descriptors for Redux Saga to manage on our behalf. Treating business logic as pure functions that are separate from side effects is an empowering concept, especially in functional languages like Elm and Haskell which already have it built in. As we progress through this series on Redux Saga and side effects, we will look at how easy it is to manage more complex side effects such as selecting store state and forking other sagas. Finally, we will revisit testing and incorporate the useful Redux Saga Test Plan library to make testing even easier.

Sources

  1. https://en.wikipedia.org/wiki/Side_effect_(computer_science)
  2. https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
  3. Repo description for http://yelouafi.github.io/redux-saga
  4. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*
  5. https://www.destroyallsoftware.com/talks/boundaries