Level 80 Overagineering or Raducer: Path from switch-case to classes

https://habr.com/en/post/439914/
  • Transfer

image


What are we talking about?


Let's look at the metamorphosis of the reducer in my Redux / NGRX applications for the last couple of years. Starting from the oak tree switch-case, continuing the selection from the object by key and ending with classes with decorators, blackjack and TypeScript. We will try to review not only the history of this path, but also to find some causal link.


If you, like me, are asking questions about getting rid of a boilerplate in Redux / NGRX, then you might be interested in this article .

If you already use the approach to selecting a reducer from an object by key and are fed up with it, you can immediately flip through to "Class-Reduced Reducers".

Chocolate switch-case


Usually switch-casevanilla, but it seemed to me that this seriously discriminated against all other types switch-case.

So, let's take a look at the typical problem of asynchronous creation of some entity, for example, a 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,
  // Список джедаев
  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
  }
}

I will be very frank and admit that I have never used it in my practice switch-case. I would like to believe that I even have a list of reasons for this:


  • switch-casetoo easy to break: you can forget to paste break, you can forget about default.
  • switch-case too verbose.
  • switch-casealmost O (n). It is not that important in itself, because Redux doesn’t brag about amazing performance by itself, but this fact infuriates my inner connoisseur of beauty.

The logical way to comb all this is offered by the official Redux documentation - to choose a reducer from an object by key.


Selecting a reducer from an object by key


The idea is simple - every change of the state can be described by a function of the state and action, and each such function has a certain key (field typein the action) that corresponds to it. Since type- a string, nothing prevents us from comprehending an object to all such functions, where the key is this typeand the value is a pure function of state transformation (reducer). In this case, we can choose the necessary reducer by key (O (1)), when a new action comes to the root 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 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) => {
  // Выбираем редьюсер по `type` экшнаconst reducer = reducerJediMap[action.type]
  if (!reducer) {
    // Возвращаем исходный стейт, если наш объект не содержит подходящего редьюсераreturn state
  }
  // Выполняем найденный редьюсер и возвращаем новый стейтreturn reducer(state, action)
}

The most delicious thing here is that the logic inside reducerJediremains the same for any reducer, and we can reuse it. There is even a redux-create-reducer nano-library for 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,
  }),
})

It seems to be nothing like that happened. True, a spoon of honey is not without a barrel of tar:


  • For complex reduser we have to leave comments, because This method does not provide a way out of the box to provide some explanatory meta-information.
  • Objects with lots of reducers and keys are not very readable.
  • Only one key corresponds to each reducer. And what if you want to run the same reducer for several action games?

I almost burst into tears of happiness when I moved to class-based reyuser on the basis of classes, and below I will tell you why.


Class-based Reducers


Buns:


  • Class methods are our reducers, and methods have names. Just the very meta-information that tells you what this reducer does.
  • Class methods can be decorated, which is a simple declarative way to link the reduction gears and the corresponding actions (just actions, not one action!)
  • Under the hood, you can use all the same objects to get O (1).

In the end, I would like to get something like that.


const actionTypeJediCreateInit = 'jedi-app/jedi-create-init'const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success'const actionTypeJediCreateError = 'jedi-app/jedi-create-error'classReducerJedi{
  // Смотрим на предложение о "Class field delcaratrions", которое нынче в 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,
    }
  }
}

I see a purpose, but I do not see obstacles.


Step 1. Decorator @Action.


We need to get any number of actions in this decorator, and to preserve these as some kind of meta-information that can be accessed later. To do this, we can use the wonderful reflect-metadata polyfill, which patches Reflect .


const METADATA_KEY_ACTION = 'reducer-class-action-metadata'exportconst Action = (...actionTypes) => (target, propertyKey, descriptor) => {
  Reflect.defineMetadata(METADATA_KEY_ACTION, actionTypes, target, propertyKey)
}

Step 2. We turn the class into, in fact, a reducer.


We drew a circle, drew a second one, and now a little magic and we get an owl!

As we know, each reducer is a pure function that takes the current state and action and returns a new state. A class is, of course, a function, but not exactly the one we need, and the ES6 classes cannot be called without new. In general, you need to somehow convert it.


So, we need a function that accepts the current class, passes through each of its methods, collects meta-information with action types, collects an object with reduction gears, and creates the final reduction gear from this object.


Let's start with the collection of meta-information.


const getReducerClassMethodsWthActionTypes = (instance) => {
  // Получаем названия методов из прототипа классаconst proto = Object.getPrototypeOf(instance)
  const methodNames = Object.getOwnPropertyNames(proto).filter(
    (name) => name !== 'constructor',
  )
  // На выходе мы хотим получить коллекцию с типами экшнов и соответствующими редьюсерамиconst res = []
  methodNames.forEach((methodName) => {
    const actionTypes = Reflect.getMetadata(
      METADATA_KEY_ACTION,
      instance,
      methodName,
    )
    // Мы хотим привязать конекст `this` для каждого методаconst method = instance[methodName].bind(instance)
    // Необходимо учесть, что каждому редьюсеру могут соответствовать несколько экшн типов
    actionTypes.forEach((actionType) =>
      res.push({
        actionType,
        method,
      }),
    )
  })
  return res
}

Now we can convert the resulting collection to an object.


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

Thus, the final function might look 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
}

Next we can apply it to our class ReducerJedi.


const reducerJedi = createClassReducer(ReducerJedi)

Step 3. We look that turned out as a result.


// Переместим общий код в отдельный модуль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'classReducerJedi{
  // Смотрим на предложение о "Class field delcaratrions", которое нынче в 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,
    }
  }
}
exportconst reducerJedi = createClassReducer(ReducerJedi)

How to live on?


Something we left behind the scenes:


  • What if the same action type matches several reducers?
  • It would be great to add immer out of the box.
  • What if we want to use classes to create our actions? Or functions (action creators)? I wish the decorator could accept not only the types of actions, but also the actions creators.

All this functionality with additional examples is in a small library reducer-class .


It is worth noting that the idea of ​​using classes for reducers is not new. @amcdnl once created a great library of ngrx-actions , but it seems that he is now scoring it and switching to NGXS . In addition, I wanted more stringent typing and reset ballast as Angular-specific functionality. Here you can find a list of key differences between the reducer-class and ngrx-actions.


If you like the idea of ​​classes for the reduction devices, then you might also like to use classes for your action games. Take a look at flux-action-class .

I hope you did not waste your time, and the article was just a little useful to you. Please kick and criticize. We will learn to code better together.


Also popular now: