Tracking asynchronous operations with redux

A frequent complaint against redux is the amount of code needed to achieve seemingly simple things. Which gets worse when those simple things are also common, leading to lots and lots of similar code. This is often dubbed boilerplate, and posed to be obviously bad (after all, Don’t Repeat Yourself is the law, right?).

Not necessarily so (I think DRY is a very overrated principle, but that’s a thought for another post). One thing the boilerplate is useful for is identifying patterns. If there’s a lot of identical code in multiple places in the codebase (not two, not three, ideally four or more), that’s a strong indicator we’re missing an abstraction.

There’s a particular use case for abstractions in redux apps - happens very often, feels repetitive, and could use a better solution.

That use case is: performing AJAX requests.

(note: if all your app really does is simple data fetching - You might not need redux)

Requirements

Let’s identify what are the requirements of a feature that fetches a list of widgets.

The redux state needs to know:

  • Whether the widgets are loading or not
  • Whether the widgets have been loaded successfully or not
  • Error details if the request has failed
  • The widget data itself

A common way to implement this feature with redux (using best practices for action creators etc) involves writing:

  • 3 action types (WIDGETS_REQUEST, WIDGETS_SUCCESS, WIDGETS_FAILURE)
  • 3 action creators (widgetsRequest, widgetsSuccess, widgetsFailure)
  • A reducer that sets the appropriate fields in the state
  • A thunk dispatching the actions and performing the ajax request itself

It might look like this:

const WIDGETS_REQUEST = 'WIDGETS_REQUEST';
const WIDGETS_SUCCESS = 'WIDGETS_SUCCESS';
const WIDGETS_FAILURE = 'WIDGETS_FAILURE';

function widgetsRequest() {
  return {
    type: WIDGETS_REQUEST
  };
}

function widgetsSuccess(widgets) {
  return {
    type: WIDGETS_SUCCESS,
    payload: { widgets }
  };
}

function widgetsFailure(error) {
  return {
    type: WIDGETS_FAILURE,
    payload: { error }
  };
}

const initialState = {
  widgets: [],
  isLoading: false,
  isLoaded: false,
  error: null,
};

function reducer(state = initialState, action) {
  switch (action.type) {
    case WIDGETS_REQUEST:
      return {
        ...initialState,
        isLoading: true,
      }
    case WIDGETS_SUCCESS:
      return {
        ...initialState,
        isLoaded: true,
        widgets: action.payload.widgets,
      }
    case WIDGETS_FAILURE:
      return {
        ...initialState,
        error: action.payload.error,
      }
    default:
      return state;
  }
}

function fetchWidgetsThunk() {
  return function(dispatch) {
    dispatch(widgetsRequest());
    fetch('/widgets.json')
      .then(response => response.json())
      .then(
        (response) => dispatch(widgetsSuccess(response.widgets)),
        (error) => dispatch(widgetsFailure(error.message)),
      );
  }
}

For the sake of simplicity, the example assumes we’re not storing our widgets in a normalized state shape (which is recommended), and instead just keep them in a list.

Even so, it’s quite a lot of code… If we’re not using the ducks pattern, it’s also spread across multiple files / directories. And don’t forget the tests for it!

The biggest overhead is in implementing the reducer that tracks the request state (isLoading, isLoaded, error fields). Let’s see if we can find an abstraction here.

Operations

We can think of fetching the widgets as an asynchronous operation. There are three possible actions (start, success, failure) that might change its state. The state changes are defined as follows:

  • start action makes the operation pending
  • success action makes the operation finished (not pending) and successful
  • failure action makes the operation finished and not successful - with optional error details

The reducer and actions behave in the same way for every operation.

Now that the functionality is defined and isolated, we can abstract it away. Imagine a createOperation function that accepts a string (unique operation name) and returns an object with action types, action creators and a reducer, already wired up to work together:

const operation = createOperation('NAME');

const { START, SUCCESS, FAILURE } = operation.actionTypes;
const { start, success, failure } = operation.actionCreators;
const { reducer, initialState } = operation;
/*
initialState = {
  pending: false,
  success: false,
  failure: false,
  errors: null
}
*/

In this state shape, isFetching and isFetched are renamed to more fitting pending and success boolean fields. Also, the failure boolean is separate from optional errors details.

When the actions start, success and failure are dispatched to the store, the generated reducer causes a state update (setting the appropriate boolean fields and error details). We also export initialState (useful for tests) and action types (so other reducers can react to the actions, for example by saving data somewhere else in the state).

const pendingState = reducer(initialState, start());
// { pending: true, success: false, failure: false, errors: null }
const successState = reducer(pendingState, success({ payloadExample: [] }));
// { pending: false, success: true, failure: false, errors: null }
const failureState = reducer(pendingState, failure('An error'));
// { pending: false, success: false, failure: true, errors: 'An error' }

With the createOperation function, the widgets feature now looks like this:

import { combineReducers } from 'redux';

const fetchOperation = createOperation('FETCH_WIDGETS');

function widgetsReducer(state = [], action) {
  switch (action.type) {
    case fetchOperation.actionTypes.SUCCESS:
      return action.payload;
    default:
      return state;
  }
}

const reducer = combineReducers({
  widgets: widgetsReducer,
  status: fetchOperation.reducer
});

function fetchWidgetsThunk() {
  return function(dispatch) {
    const { start, success, failure } = fetchOperation.actionCreators;
    dispatch(start());
    fetch('/widgets.json')
      .then(response => response.json())
      .then(
        (response) => dispatch(success(response.widgets)),
        (error) => dispatch(failure(error.message))
      );
  }
}

This is much shorter! The mundane details of tracking the pending / successful / error states are abstracted away by the fetchOperation, and stored in a separate slice of state (in this case, status).

Summary

There are a multitude of existing ways to handle AJAX requests with redux. However, they still require implementing the same reducer logic over and over again.

Using the operation abstraction has following benefits:

  • Reduces the amount of redux code / test code written for tracking the request status. This decreases the possibility of bugs.
  • Decouples data state (widget attributes) from meta-data state (are widgets loading?).
  • Can be used with thunks, sagas, custom middlewares, or any other method for handling async effects. Simply use the generated action creators!
  • Does not depend on anything other than the existing redux abstractions.
  • By exporting action types, other reducers can react to single operation state changes as well.
  • Can be used to track any kind of asynchronous operation, not limited to fetching and updating data via AJAX.

To me, this solution shows the beauty of redux: it is simple and powerful. Because redux is so unopinionated, and uses plain objects and functions (no magic), it can sometimes feel verbose. But developers are able to create and compose elegant abstractions on top of it.

Speaking of which, implementing the createOperation function is left as an excercise to the reader. You can treat it as a programming koan to sharpen your skills. If you’re interested, my implementation is available below:

See the code

function createOperation(name) {
  const START = `${name}/START`;
  const SUCCESS = `${name}/SUCCESS`;
  const FAILURE = `${name}/FAILURE`;

  const start = (payload) => ({ type: START, payload });
  const success = (payload) => ({ type: SUCCESS, payload });
  const failure = (payload) => ({ type: FAILURE, payload });

  const initialState = {
    pending: false,
    success: false,
    failure: false,
    errors: null
  };

  const reducer = (state = initialState, action) => {
    switch(action.type) {
      case START:
        return { ...initialState, pending: true };
      case SUCCESS:
        return { ...initialState, success: true };
      case FAILURE:
        return { ...initialState, failure: true, errors: action.payload };
      default:
        return state;
    }
  };

  const actionCreators = { start, success, failure };
  const actionTypes = { START, SUCCESS, FAILURE };

  return { actionCreators, actionTypes, initialState, reducer };
}

comments powered by Disqus