Preamble

On this post we will share a couple of lessons learned while working on SpiderOak Semaphor, where we use:

  • Javascript and React to build our UI components.
  • Jest to write our tests.
  • Redux to serve as single source of truth for our data.

Even though this post shows functions in the context of Redux, you can extrapolate the idea to any javascript function.
We'll show tests using Jest, but you could use any other tool you like. Jest tests are pretty readable on its own even if you haven't seen jest tests before.

You should be able to get some ideas from this post even if you don't know redux/jest.

TL;DR ?

  • Use single purpose functions to simplify your code.
  • Add tests to the mix and you'll have code that you can trust.
  • Factor out your reducer's logic into more manageable functions instead of a giant switch.
  • You now have a more maintainable and trustworthy codebase.

For more details, keep reading :)

Redux's reducers

Reducers specify how the application's state changes in response to actions sent to the store. Remember that actions only describe what happened, but don't describe how the application's state changes.
(from: https://redux.js.org/basics/reducers)

Reducers are presented (almost always) as large functions with a switch to decide what to do depending on the action received.

Let's see why this is a problem and how we can use a better approach.

Example and problems

Let's say we want to store some users on Redux. We'll need actions to add and remove a user.

This is usually how we see reducers on tutorials:

// e.g. src/actions/users.js
export const USER_ADD = "USER_ADD";
export const USER_REMOVE = "USER_REMOVE";

// e.g. src/reducers/usersById.js
export function usersById(state = {}, action) {
  let newState;
  switch (action.type) {
    case USER_ADD:
      return {
        ...state,
        [action.user.id]: action.user,
      };
    case USER_REMOVE:
      newState = { ...state };
      delete newState[action.id];
      return newState;
    default:
      return state;
  }
}

This approach has several problems:

  • The function gets very long and too nested, which makes it harder to read, harder to test, and more error prone.
  • The function has several responsibilities, which makes it harder to document. You would add a big docstring that explains all the things this function does.
  • Lexical scope sharing on the switch, read more about that here: https://eslint.org/docs/rules/no-case-declarations.
  • Is not obvious what data you expect to get from the action.

Note that these problems are really noticeable as the codebase grows, not really for such contrived example.

Adding tests

We will fix our problems by doing some refactor, but before that, we'll write some tests for our reducer.

Disclaimer: real world code will be more complex, and adding tests will make more sense than on this contrived example.

I recommend you write tests before starting to make changes to your working code, so you can be sure it still works after the changes.

Here's an example (using jest) of how a test for this use case could look:

// e.g. src/reducers/usersById.test.js
import { userAdd } from 'actions/users';
import { usersById } from 'reducers/usersById';

test('on USER_ADD, with empty state', () => {
  // define your initial state
  const initialState = {};

  // define payload for the action we'll use to test the reducer
  const user = { id: 'user-id-1', username: 'john-doe-01' };

  // define expected state
  const expectedState = {
    [user.id]: user,
  };

  // call our reducer
  const result = usersById(initialState, userAdd(user));

  // check that the result we got is what we need
  expect(result).toEqual(expectedState);

  // check that we don't mutate the state
  expect(result).not.toBe(initialState);
});

Note that to test our reducers we don't even need Redux, they are just functions.

We don't (directly) test our action creators since they should be as simple as:

// e.g. src/actions/users.js
export const USER_ADD = "USER_ADD";

export function userAdd(user) {
  return {
    type: USER_ADD,
    user,
  }
}

We test them indirectly by calling them within our reducer's test, which should be enough.

Single purpose functions

This is a very simple concept, nothing special, just a function with a single purpose instead of multiple ones.

Our reducer is presented as a "monolithic" function with a giant switch. It has several problems, as we listed above.

We'll split it up into smaller, single-purposed, and more manageable functions. Let's dig into it.

Refactoring

Now that we have tests for our reducer, we can confidently refactor them or swap implementations with a different one without breaking the rest of our code.

Let's tackle the problems we listed for our example.

Using the strategy proposed on the redux docs we can do some refactor on our reducers to make them look like this:

// e.g. src/actions/users.js
export const USER_ADD = "USER_ADD";
export const USER_REMOVE = "USER_REMOVE";

// e.g. src/reducers/usersById.js
export function usersById(state = {}, action) {
  switch (action.type) {
    case USER_ADD: return addUser(state, action.user);
    case USER_REMOVE: return removeUser(state, action.id);

    default: return state;
  }
}

function addUser(state, user) {
  return {
    ...state,
    [user.id]: user,
  };
}

function removeUser(state, id) {
  const newState = { ...state };
  delete newState[id];
  return newState;
}

The refactor should be pretty straightforward, just extract the usual reducer logic for each case, into functions.

Let's contrast this approach with the problems we saw on the earlier example:

  • Each function is pretty short and concise, which makes them easier to read and test.
  • Each function has its own scope.
  • You can add a simple docstring for each function. It should be easy to keep it short since it shouldn't do too much, especially comparing to the "do it all" previous approach.
  • It is obvious what data you expect to get from the action for each function.

Run some code

If you want to take a look at our example with more tests and a couple of extra actions go to this repo.

Or, to play with it live, thanks to CodeSandbox:

Edit test-refactor-redux-reducers