📜 ⬆️ ⬇️

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

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 with the oak 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-case vanilla, but it seemed to me that this seriously discriminated against all other types of 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 a switch-case in my practice. I would like to believe that I even have a list of reasons for this:



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 (the type field in the action) that corresponds to it. Since type is a string, nothing prevents us from comprehending an object to all such functions, where the key is type and the value is a pure state transformation function (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 reducerJedi remains 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:



I almost burst into tears of happiness when I moved to class-based reducers, and below I’ll tell you why.


Class-based Reducers


Buns:



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' class ReducerJedi { // Смотрим на предложение о "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. @Action decorator.


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' export const 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 ReducerJedi class.


 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' class ReducerJedi { // Смотрим на предложение о "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, } } } export const reducerJedi = createClassReducer(ReducerJedi) 

How to live on?


Something we left behind the scenes:



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.



Source: https://habr.com/ru/post/440524/