DEV Community

Cover image for Reducer organization - taking a step further
Andrey Goncharov for ITNEXT

Posted on • Edited on • Originally published at blog.goncharov.page

Reducer organization - taking a step further

I now have a new shiny blog. Read this article with the latest updates there https://blog.goncharov.page/reducer-organization-taking-a-step-further

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.

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.

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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

  • switch-case introduces some points of tension, leaky pipes, which we might forget to patch up in time at some point. We could always forget to put in break if do not do immediate return, we could always forget to add default, which we have to add to every reducer.
  • switch-case has some boilerplate code itself which doesn't add any context.
  • switch-case is O(n), kind of. It is not a solid argument by itself because Redux is not very performant anyway, but it makes my inner perfectionist mad.

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)
}
Enter fullscreen mode Exit fullscreen mode

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,
  }),
})
Enter fullscreen mode Exit fullscreen mode

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

  • In case of complex reducers we have to leave lots of comments describing what this reducer does and why.
  • Huge reducer maps are hard to read.
  • Each reducer has only one corresponding action type. What if I want to run the same reducer for several actions?

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:

  • Class' methods will be our reducers and methods have names, which is a useful meta-information, and we could abandon comments in 90% of cases.
  • Class' methods could be decorated which is an easy-to-read declarative way to match actions and reducers.
  • We still could use a map of actions under the hood to have O(1) complexity.

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,
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
  }, {})
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

const reducerJedi = createClassReducer(ReducerJedi)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Next steps

Here's what we missed:

  • What if the same action corresponds to several methods? Current logic doesn't handle this.
  • Could we add immer?
  • What if I use class-based actions? How could I pass an action creator, not an action type?

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.

Top comments (0)