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".
Usuallyswitch-case
vanilla, but it seemed to me that this seriously discriminated against all other types ofswitch-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:
switch-case
too easy to break: you can forget to insert break
, you can forget about default
.switch-case
too verbose.switch-case
almost 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.
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.
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.
@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) }
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)
// Переместим общий код в отдельный модуль 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)
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/