React transformed the front-end space, bringing functional programming back into broad acclaim with its component-based, declarative nature. The DOM is virtualized, abstracting away the reconciliation process and rendering process, to let developers focus on modeling the schema and flow of state.

Redux came shortly after, inspired from Elm patterns, as a way to manage your state. Data flows in one direction from a single store to top-level containers down to pure, stateless functional components. Actions are dispatched from the view layer and reducers subscribe to these actions to mutate state as needed. As a new immutable state tree is constructed by React it is propagated to components. React then determines the minimal DOM transformations required for the change.

Together React and Redux have created a one-two punch for front-end development. Gone are the days of spaghetti jQuery and large opinionated libraries such as Angular. React is quick to learn as it’s mostly vanilla ES6 syntax with a few React-centric design patterns and lightweight-APIs to understand. The adoption of React has inspired a number more libraries with similar concepts (ie. VueJS, Preact) and motivated libraries such as Angular to reinvent themselves (ie. Angular2). The one missing puzzle piece with React and Redux is how to handle asynchronicity. This is where a collection of middlewares have sprung up. Each has its own take on how to address asynchronicity. Let’s delve into some of the more widely-adopted middlewares, what they bring to the table, and what their drawbacks are: Redux Thunk, Redux Loop, Redux Saga, and Redux Observable.

Redux Thunk

Thunks are perhaps the most-widely adopted and simplest of the middlewares we will discuss. It is based on the premise that actions can not only be small objects, but functions as well. Actions returning a promise, called thunks, are dispatched asynchronously without blocking subsequent actions. Once the asynchronous task is completed a second action is typically dispatched through resolution of the promise. This second action is either a success or a failure with the relevant data attached. Reducers then listen for these success or failure actions and mutate state accordingly as they do with normal synchronous actions (example #1).

A great library for simpler applications, thunks begin running into hurdles once more complex sequences of asynchronous flows are required. It can be difficult with thunks to orchestrate numerous inter-dependent asynchronous actions, canceling requests, or debouncing high frequency actions. Additionally, thunks being actions only have access to the data passed to them unless calling getState(). This means either data not part of the dispatched action needs to be passed to the thunk to make control flow decisions (example #2); the component calling the thunk needs additional state and logic to make these decisions, which can quickly break DRY principles; or the thunk must utilize getState(), increasing the complexity and functional impurity of your thunk (example #3).


// Redux Thunk example #1
function asyncRequest() {
  return dispatch => {
    dispatch({ type: 'REQUEST_START' });
    fetch(example_url)
      .then(response => {
        dispatch({ type: 'REQUEST_SUCCESS', response });
      })
      .catch(error => {
        dispatch({ type: ‘REQUEST_FAILURE', error });
      });
  }
}

// Redux Thunk example #2
function asyncRequest(id, alreadyFetchedIds) {
  if (alreadyFetchedIds[id]) return;  // abort if id has already been fetched
  return dispatch => {
    dispatch({ type: 'REQUEST_START' });
    fetch(example_url)
      .then(response => {
        dispatch({ type: 'REQUEST_SUCCESS', response });
      })
      .catch(error => {
        dispatch({ type: ‘REQUEST_FAILURE', error });
      });
  }
}

// Redux Thunk example #3
function asyncRequest(id) {
  return (dispatch, getState) => {
    const state = getState();
    if (state.ids[id]) return;  // abort if id has already been fetched
    dispatch({ type: 'REQUEST_START' });
    fetch(example_url)
      .then(response => {
        dispatch({ type: 'REQUEST_SUCCESS', response });
      })
      .catch(error => {
        dispatch({ type: ‘REQUEST_FAILURE', error });
      });
  }
}

Let’s see how the other middlewares have solved these drawbacks.

Redux Loop

With Loops, reducers not only determine how state should be mutated immediately given an action, but what should happen next as well. Loops fix the incorrect notion of thunks that asynchronous and synchronous actions are fundamentally disparate concepts. Actions are always just a simple object and the responsibility of asynchronous effects are shifted to the reducer where these effects are declarative. This declarative style makes Loops very easy to test, but still doesn’t address the difficulty incurred with orchestrating numerous inter-dependent asynchronous actions, canceling requests, or debouncing high frequency actions.


function asyncRequestPromise {
  return fetch(example_url)
    .then(response => requestSuccess(response))
    .catch(error => requestFailure(error));
}

function reducer(state, action) {
  switch(action.type) {
    case REQUEST_START:
      return loop ({
        state: {
          ...state,
          isRequesting: true,
        },
        Effects.call(asyncRequestPromise)
      );
    case REQUEST_SUCCESS:
      return {
        ...state,
        isRequesting: false,
        results: action.results,
      };
  }
}

Furthermore, with the default Redux combineReducers(), each reducer only has access to the slice of state it is responsible for. This direct access to state makes control-flow decisions easy when based on state within the domain of a single slice of state, but as soon as you must make control-flow decisions based on several slices of state, not all accessible from a single reducer, you must either utilize a custom combineReducers() function or dispatch multiple subsequent actions, spreading this logic across multiple reducers. For example, when using hash-based routing, say on navigation you want to fetch state that depends on another slice of state being loaded (see Beyond CombineReducers()). Let’s see how Sagas address these concerns.

Redux Saga

Sagas move asynchronicity into a totally separate construct within your application. In addition to actions and reducers, sagas are introduced. Sagas utilize ES6 generators to listen for actions as Reducers do and yield new actions as responses are received. Using generators and the local state of the generator functions, enables sagas to quite easily track and orchestrate numerous asynchronous processes, cancel said processes, as well as debounce high frequency requests.

Depending on your familiarity with ES6 generators or whether you are coming from other languages that have similar concepts (ie. C#), sagas may have a slightly steeper learning curve than loops or thunks, but it is well worth it. In addition to the benefits generators provide in simpler error handling, sagas let you declaratively yield effects, making them easy to test. The Redux Saga library also has a much more extensive API than Loop’s, providing many utility functions and effects to handle your different needs such as racing requests, consuming multiple parallelizable requests, debouncing requests, etc.

Drawbacks include that sagas always receive actions after reducers. This means sagas cannot “swallow” actions which for the most part is great, but does restrict you from updating state as part of your asynchronous needs without chaining actions. For example, if setting a isFetching flag when making a fetch requires: request action > fetching flag action > fetch > success/fail.


function* asyncRequest(action) {
   try {
      const response = yield call(example_url);
      yield put(requestSuccess(response));
   } catch (error) {
      yield put(requestFailure(error));
   }
}

function* mySaga() {
  yield takeEvery("REQUEST_START", asyncRequest);
}

Redux Observable

Redux Observable integrates RxJS with the Redux store. RxJS is a popular reactive programming library, emphasizing the declaration of data transformations and their effects. Similar to sagas, Redux Observable creates a separate application construct for asynchronicity in epics and these again are not evaluated until after Reducers (see Netflix’s talk).

Epics are simply functions which given a stream of actions, make transformations, and returns a new stream of actions. These streams of actions are called observables, hence the name of this library.

You can think of observables as related to promises, but rather than a promise’s single future value, observables are a series of future values. This is a very powerful concept. Observables can be constructed from primitive values, callback functions, promises, or other observables!

RxJS provides a large library of operators to transform observables, making composing, canceling, and debouncing asynchronous actions a breeze, all while maintaining immutability by not altering the original Observable. Key functional JS methods can be used similarly on observables - mapping, filtering, reducing, etc. These observables, or streams, can be composed via merging, racing, zipping, etc. Some great examples can be found here - https://www.learnrxjs.io/


const asyncRequest = action$ =>
  action$.ofType(REQUEST_START)
    .mergeMap(action =>
      ajax.getJSON(example_url)
        .map(response => requestSuccess(response))
        .takeUntil(action$.ofType(REQUEST_CANCELLED))
    )
    .catch(error => Observable.of(requestFailure(error)));

Those with simple use-cases will be happy with any of the listed middlewares, likely preferring the ease of thunks or loops. For larger projects and more complex requirements, sagas or observables may make more sense. Redux Observable will require the steepest learning curve of the aforementioned Redux async middlewares, but potentially the most upside. Once grasped, observables offer a great combination of a comprehensive API, testability, functional purity, and ability to perform complex asynchronous patterns. It is up to each project, their use case, and team’s capabilities to determine which is best.

Check back or follow along on Twitter.