Fail. Learn. Repeat.

Reducer organization - taking a step further

What are we gonna cover here?

We’re going to overview evolution of reducers in my Redux/NGRX apps that took place over the last two years. Starting from vanilla switch-case, going to selecting a reducer from an object by key, finally settling with class-based reducers. We’re not going to talk only about how, but also about why.

If you’re interested in working around too much boilerplate in Redux / NGRX you might want to check this article out.

If you’re already familiar with selecting a reducer from a map technique consider jumping right to class-based reducers.

Vanilla switch-case

So let’s take a look at an everyday task of creating an entity on the server asynchronously. This time I suggest we describe how we could create a new jedi.

const actionTypeJediCreateInit = 'jedi-app/jedi-create-init'
const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success'
const actionTypeJediCreateError = 'jedi-app/jedi-create-error'

const reducerJediInitialState = {
  loading: false,
  // List of our jedi
  data: [],
  error: undefined,
}
const reducerJedi = (state = reducerJediInitialState, action) => {
  switch (action.type) {
    case actionTypeJediCreateInit:
      return {
        ...state,
        loading: true,
      }
    case actionTypeJediCreateSuccess:
      return {
        loading: false,
        data: [...state.data, action.payload],
        error: undefined,
      }
    case actionTypeJediCreateError:
      return {
        ...state,
        loading: false,
        error: action.payload,
      }
    default:
      return state
  }
}

Let me be honest, I’ve never used this kind of reducers in production. My reasoning is threefold:

The logical next step which Redux’s official documentation suggests to take is to pick a reducer from an object by key.

Selecting a reducer from an object by key

The idea is simple. Each state transformation is a function from state and action and has a corresponding action type. Considering that each action type is a string we could create an object, where each key is an action type and each value is a function that transforms state (a reducer). Then we could pick a required reducer from that object by key, which is O(1), when we receive a new action.

const actionTypeJediCreateInit = 'jedi-app/jedi-create-init'
const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success'
const actionTypeJediCreateError = 'jedi-app/jedi-create-error'

const reducerJediInitialState = {
  loading: false,
  data: [],
  error: undefined,
}
const reducerJediMap = {
  [actionTypeJediCreateInit]: (state) => ({
    ...state,
    loading: true,
  }),
  [actionTypeJediCreateSuccess]: (state, action) => ({
    loading: false,
    data: [...state.data, action.payload],
    error: undefined,
  }),
  [actionTypeJediCreateError]: (state, action) => ({
    ...state,
    loading: false,
    error: action.payload,
  }),
}

const reducerJedi = (state = reducerJediInitialState, action) => {
  // Pick a reducer by action type
  const reducer = reducerJediMap[action.type]
  if (!reducer) {
    // Return state unchanged if we did not find a suitable reducer
    return state
  }
  // Run suitable reducer if found one
  return reducer(state, action)
}

The cool thing here is that logic inside reducerJedi stays the same for any reducer, which means we can re-use it. There’s even a small library, called redux-create-reducer, which does exactly that. It makes the code looks like this:

import { createReducer } from 'redux-create-reducer'

const actionTypeJediCreateInit = 'jedi-app/jedi-create-init'
const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success'
const actionTypeJediCreateError = 'jedi-app/jedi-create-error'

const reducerJediInitialState = {
  loading: false,
  data: [],
  error: undefined,
}
const reducerJedi = createReducer(reducerJediInitialState, {
  [actionTypeJediCreateInit]: (state) => ({
    ...state,
    loading: true,
  }),
  [actionTypeJediCreateSuccess]: (state, action) => ({
    loading: false,
    data: [...state.data, action.payload],
    error: undefined,
  }),
  [actionTypeJediCreateError]: (state, action) => ({
    ...state,
    loading: false,
    error: action.payload,
  }),
})

Nice and pretty, huh? Though this pretty still has a few caveats:

Class-based reducer became my shed of light in the kingdom of the night.

Class-based reducers

This time let me start with whys of this approach:

If that’s sounds like a reasonable list of reasons for you, let’s dig in!

First of all, I would like to define what we want to get as a result.

const actionTypeJediCreateInit = 'jedi-app/jedi-create-init'
const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success'
const actionTypeJediCreateError = 'jedi-app/jedi-create-error'

class ReducerJedi {
  // Take a look at "Class field delcaratrions" proposal, which is now at Stage 3.
  // https://github.com/tc39/proposal-class-fields
  initialState = {
    loading: false,
    data: [],
    error: undefined,
  }

  @Action(actionTypeJediCreateInit)
  startLoading(state) {
    return {
      ...state,
      loading: true,
    }
  }

  @Action(actionTypeJediCreateSuccess)
  addNewJedi(state, action) {
    return {
      loading: false,
      data: [...state.data, action.payload],
      error: undefined,
    }
  }

  @Action(actionTypeJediCreateError)
  error(state, action) {
    return {
      ...state,
      loading: false,
      error: action.payload,
    }
  }
}

Now as we see where we want to get we could do it step by step.

Step 1. @Action decorator.

What we want to do here is to accept any number of action types and store them as meta-information for a class’ method to use later. To do that we could utilize reflect-metadata polyfill, which brings meta-data functionality to Reflect object. After that this decorator would just attach its arguments (action types) to a method as meta-data.

const METADATA_KEY_ACTION = 'reducer-class-action-metadata'

export const Action = (...actionTypes) => (target, propertyKey, descriptor) => {
  Reflect.defineMetadata(METADATA_KEY_ACTION, actionTypes, target, propertyKey)
}

Step 2. Creating a reducer function out of a reducer class

As we know each reducer is a pure function that accepts a state and an action and returns a new state. Well, class is a function as well, but ES6 classes can not be invoked without new and we have to make an actual reducer out of a class with a few methods anyway. So we need to somehow transform it.

We need a function that would take our class, walk though each method, collect metadata with action types, build an reducer map and create a final reducer out of that reducer map.

Here’s how we could examine each method of a class.

const getReducerClassMethodsWthActionTypes = (instance) => {
  // Get method names from class' prototype
  const proto = Object.getPrototypeOf(instance)
  const methodNames = Object.getOwnPropertyNames(proto).filter(
    (name) => name !== 'constructor',
  )

  // We want to get back a collection with action types and corresponding reducers
  const res = []
  methodNames.forEach((methodName) => {
    const actionTypes = Reflect.getMetadata(
      METADATA_KEY_ACTION,
      instance,
      methodName,
    )
    // We want to bind each method to class' instance not to lose `this` context
    const method = instance[methodName].bind(instance)
    // We might have many action types associated with a reducer
    actionTypes.forEach((actionType) =>
      res.push({
        actionType,
        method,
      }),
    )
  })
  return res
}

Now we want to process the received collection into a reducer map.

const getReducerMap = (methodsWithActionTypes) =>
  methodsWithActionTypes.reduce((reducerMap, { method, actionType }) => {
    reducerMap[actionType] = method
    return reducerMap
  }, {})

So the final function could look something like this.

import { createReducer } from 'redux-create-reducer'

const createClassReducer = (ReducerClass) => {
  const reducerClass = new ReducerClass()
  const methodsWithActionTypes = getReducerClassMethodsWthActionTypes(
    reducerClass,
  )
  const reducerMap = getReducerMap(methodsWithActionTypes)
  const initialState = reducerClass.initialState
  const reducer = createReducer(initialState, reducerMap)
  return reducer
}

And we could apply it to our ReducerJedi class like this.

const reducerJedi = createClassReducer(ReducerJedi)

Step 3. Merging it all together.

// We move that generic code to a dedicated module
import { Action, createClassReducer } from 'utils/reducer-class'

const actionTypeJediCreateInit = 'jedi-app/jedi-create-init'
const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success'
const actionTypeJediCreateError = 'jedi-app/jedi-create-error'

class ReducerJedi {
  // Take a look at "Class field delcaratrions" proposal, which is now at Stage 3.
  // https://github.com/tc39/proposal-class-fields
  initialState = {
    loading: false,
    data: [],
    error: undefined,
  }

  @Action(actionTypeJediCreateInit)
  startLoading(state) {
    return {
      ...state,
      loading: true,
    }
  }

  @Action(actionTypeJediCreateSuccess)
  addNewJedi(state, action) {
    return {
      loading: false,
      data: [...state.data, action.payload],
      error: undefined,
    }
  }

  @Action(actionTypeJediCreateError)
  error(state, action) {
    return {
      ...state,
      loading: false,
      error: action.payload,
    }
  }
}

export const reducerJedi = createClassReducer(ReducerJedi)

Next steps

Here’s what we missed:

All of it with additional code samples and examples is covered with reducer-class.

I must say using classes for reducers is not an original thought. @amcdnl came up with awesome ngrx-actions quite a while ago, but it seems like he’s now focused on NGXS, not to mention I wanted more strict typing and decoupling from Angular-specific logic. Here’s a list of key differences between reducer-class and ngrx-actions.

If you like the idea of using classes for your reducers, you might like to do the same for your action creators. Take a look at flux-action-class.

Hopefully, you found something useful for your project. Feel free to communicate your feedback to me! I most certainly appreciate any criticism and questions.