Let's talk about a few (five, to be specific) ways, tricks, bloody sacrifices to the god of enterprise, which are supposed to help us write more concise and expressive code in our Redux (and NGRX!) Applications. Ways suffered through coffee and sweat. Please strongly kick and criticize. We will learn to code better together.
Honestly, I first just wanted to tell the world about my new micro-library (35 lines of code!) Flux-action-class , but looking at the ever-increasing number of exclamations that Habr will soon become Twitter, and for the most part agreeing with them, I decided to try to do some more capacious reading. So, we meet 5 ways to pump your Redux app!
Consider a typical example of how to send an AJAX request to Redux. Let's imagine that we really need a list of cats from the server.
import { createSelector } from 'reselect' const actionTypeCatsGetInit = 'CATS_GET_INIT' const actionTypeCatsGetSuccess = 'CATS_GET_SUCCESS' const actionTypeCatsGetError = 'CATS_GET_ERROR' const actionCatsGetInit = () => ({ type: actionTypeCatsGetInit }) const actionCatsGetSuccess = (payload) => ({ type: actionTypeCatsGetSuccess, payload, }) const actionCatsGetError = (error) => ({ type: actionTypeCatsGetError, payload: error, }) const reducerCatsInitialState = { error: undefined, data: undefined, loading: false, } const reducerCats = (state = reducerCatsInitialState, action) => { switch (action.type) { case actionTypeCatsGetInit: return { ...state, loading: true, } case actionCatsGetSuccess: return { error: undefined, data: action.payload, loading: false, } case actionCatsGetError: return { ...data, error: action.payload, loading: false, } default: return state } } const makeSelectorCatsData = () => createSelector( (state) => state.cats.data, (cats) => cats, ) const makeSelectorCatsLoading = () => createSelector( (state) => state.cats.loading, (loading) => loading, ) const makeSelectorCatsError = () => createSelector( (state) => state.cats.error, (error) => error, )
If you don’t really understand why you need factories for selectors, you can read about it here.
I deliberately do not consider side effects here. This is a topic for a separate article full of teenage anger and criticism of the existing ecosystem: D
This code has several weak points:
loading
flag. The data that we store in data
and their form can vary significantly from request to request, but the loading indicator ( loading
flag) will be the same.switch
execution time is O (n) (well, almost ). By itself, this is not a very strong argument, because Redux, in principle, is not about performance. It infuriates me more that for each case
it is necessary to write a couple of extra lines of serving code, and that one switch
cannot easily and beautifully be broken into several.Well, not quite. Simply we will force JS to create them for us.
Let us think for a second about why we need action types in general. Well, obviously, in order to launch the necessary branch of logic in our reducer and change the application state accordingly. The real question is, does a type necessarily have to be a string? And what if we used classes and did switch
on type?
class CatsGetInit {} class CatsGetSuccess { constructor(responseData) { this.payload = responseData } } class CatsGetError { constructor(error) { this.payload = error this.error = true } } const reducerCatsInitialState = { error: undefined, data: undefined, loading: false, } const reducerCats = (state = reducerCatsInitialState, action) => { switch (action.constructor) { case CatsGetInit: return { ...state, loading: true, } case CatsGetSuccess: return { error: undefined, data: action.payload, loading: false, } case CatsGetError: return { ...data, error: action.payload, loading: false, } default: return state } }
Everything seems to be cool, but there is one problem: we have lost the serialization of our action games. These are no longer simple objects that we can convert to and from a string. Now we rely on the fact that each action has its own unique prototype, which, in fact, allows such a construction, like a switch
by action.constructor
, to work. You know, I really like the idea of ​​serializing my action games into a string and sending them along with a bug report, and I'm not ready to refuse it.
So, every action should have a type
field ( here you can see what else every action respecting action should have). Fortunately, each class has a name that seems to be a string. Let's add to each class a getter type
that will return the name of this class.
class CatsGetInit { constructor() { this.type = this.constructor.name } } const reducerCats = (state, action) => { switch (action.type) { case CatsGetInit.name: return { ...state, loading: true, } //... } }
It even works, but I would also like each type to stick a prefix, as Mr. Eric suggests in ducks-modular-redux (I recommend looking at the fork re-ducks , which is even cooler, as for me). In order to add a prefix, we have to stop using the name of the class directly, and add another getter. Now static.
class CatsGetInit { get static type () { return `prefix/${this.name}` } constructor () { this.type = this.constructor.type } } const reducerCats = (state, action) => { switch (action.type) { case CatsGetInit.type: return { ...state, loading: true, } //... } }
Let's get the whole thing together a bit. Minimize copy-paste and add one more condition: if the action is an error, then its payload
should be of type Error
.
class ActionStandard { get static type () { return `prefix/${this.name}` } constructor(payload) { this.type = this.constructor.type this.payload = payload this.error = payload instanceof Error } } class CatsGetInit extends ActionStandard {} class CatsGetSuccess extends ActionStandard {} class CatsGetError extends ActionStandard {} const reducerCatsInitialState = { error: undefined, data: undefined, loading: false, } const reducerCats = (state = reducerCatsInitialState, action) => { switch (action.type) { case CatsGetInit.type: return { ...state, loading: true, } case CatsGetSuccess.type: return { error: undefined, data: action.payload, loading: false, } case CatsGetError.type: return { ...data, error: action.payload, loading: false, } default: return state } }
At this stage, this code works fine with NGRX, but Redux is not capable of chewing. He swears that the action should be simple objects. Fortunately, JS allows us to return almost anything from the constructor, and we really don’t really need a prototype chain after creating an action.
class ActionStandard { get static type () { return `prefix/${this.name}` } constructor(payload) { return { type: this.constructor.type, payload, error: payload instanceof Error } } } class CatsGetInit extends ActionStandard {} class CatsGetSuccess extends ActionStandard {} class CatsGetError extends ActionStandard {} const reducerCatsInitialState = { error: undefined, data: undefined, loading: false, } const reducerCats = (state = reducerCatsInitialState, action) => { switch (action.type) { case CatsGetInit.type: return { ...state, loading: true, } case CatsGetSuccess.type: return { error: undefined, data: action.payload, loading: false, } case CatsGetError.type: return { ...data, error: action.payload, loading: false, } default: return state } }
On the basis of the above considerations, a micro-library flux-action-class was written. There are tests, 100% code coverage with tests and almost the same ActionStandard
class seasoned with generics for TypeScript needs. Works with both TypeScript and JavaScript.
The idea is simple to disgrace: use combineReducers not only for top-level reducers, but also to further partition the logic and create a separate reduction gear for loading
.
const reducerLoading = (actionInit, actionSuccess, actionError) => ( state = false, action, ) => { switch (action.type) { case actionInit.type: return true case actionSuccess.type: return false case actionError.type: return false } } class CatsGetInit extends ActionStandard {} class CatsGetSuccess extends ActionStandard {} class CatsGetError extends ActionStandard {} const reducerCatsData = (state = undefined, action) => { switch (action.type) { case CatsGetSuccess.type: return action.payload default: return state } } const reducerCatsError = (state = undefined, action) => { switch (action.type) { case CatsGetError.type: return action.payload default: return state } } const reducerCats = combineReducers({ data: reducerCatsData, loading: reducerLoading(CatsGetInit, CatsGetSuccess, CatsGetError), error: reducerCatsError, })
And again, an extremely simple idea: instead of switch-case
use an object from which to select the desired field by key. Access to an object's field by key is O (1), and it looks cleaner in my humble opinion.
const createReducer = (initialState, reducerMap) => ( state = initialState, action, ) => { // const reducer = state[action.type] if (!reducer) { return state } // , return reducer(state, action) } const reducerLoading = (actionInit, actionSuccess, actionError) => createReducer(false, { [actionInit.type]: () => true, [actionSuccess.type]: () => false, [actionError.type]: () => false, }) class CatsGetInit extends ActionStandard {} class CatsGetSuccess extends ActionStandard {} class CatsGetError extends ActionStandard {} const reducerCatsData = createReducer(undefined, { [CatsGetSuccess.type]: () => action.payload, }) const reducerCatsError = createReducer(undefined, { [CatsGetError.type]: () => action.payload, }) const reducerCats = combineReducers({ data: reducerCatsData, loading: reducerLoading(CatsGetInit, CatsGetSuccess, CatsGetError), error: reducerCatsError, })
Let's reducerLoading
. Now, knowing about the mapy (objects) for reducer, we can return this same map from reducerLoading
, instead of returning a whole reducer. Potentially, this opens up unlimited scope for expanding the functional.
const createReducer = (initialState, reducerMap) => ( state = initialState, action, ) => { // const reducer = state[action.type] if (!reducer) { return state } // , return reducer(state, action) } const reducerLoadingMap = (actionInit, actionSuccess, actionError) => ({ [actionInit.type]: () => true, [actionSuccess.type]: () => false, [actionError.type]: () => false, }) class CatsGetInit extends ActionStandard {} class CatsGetSuccess extends ActionStandard {} class CatsGetError extends ActionStandard {} const reducerCatsLoading = createReducer( false, reducerLoadingMap(CatsGetInit, CatsGetSuccess, CatsGetError), ) /* reducerCatsLoading: const reducerCatsLoading = createReducer( false, { ...reducerLoadingMap(CatsGetInit, CatsGetSuccess, CatsGetError), ... some custom stuff } ) */ const reducerCatsData = createReducer(undefined, { [CatsGetSuccess.type]: () => action.payload, }) const reducerCatsError = createReducer(undefined, { [CatsGetError.type]: () => action.payload, }) const reducerCats = combineReducers({ data: reducerCatsData, loading: reducerCatsLoading), error: reducerCatsError, })
The official documentation on Redux also talks about this approach , however, for some unknown reason, I continue to see many projects using switch-case
. Based on the code from the official documentation, Mr. Moshe wrote down the library for createReducer
for us.
We absolutely do not need to keep a mistake for each entity separately. In most cases, we just want to show the dialogue. The same dialog with dynamic text for all entities.
Create a global error handler. In the simplest case, it might look like this:
class GlobalErrorInit extends ActionStandard {} class GlobalErrorClear extends ActionStandard {} const reducerError = createReducer(undefined, { [GlobalErrorInit.type]: (state, action) => action.payload, [GlobalErrorClear.type]: (state, action) => undefined, })
Then in our side effect we will send the ErrorInit
action in a catch
. This might look something like this when using redux-thunk :
const catsGetAsync = async (dispatch) => { dispatch(new CatsGetInit()) try { const res = await fetch('https://cats.com/api/v1/cats') const body = await res.json() dispatch(new CatsGetSuccess(body)) } catch (error) { dispatch(new CatsGetError(error)) dispatch(new GlobalErrorInit(error)) } }
Now we can get rid of the error
field in our kitty store and use CatsGetError
only to switch the loading
flag.
class CatsGetInit extends ActionStandard {} class CatsGetSuccess extends ActionStandard {} class CatsGetError extends ActionStandard {} const reducerCatsLoading = createReducer( false, reducerLoadingMap(CatsGetInit, CatsGetSuccess, CatsGetError), ) const reducerCatsData = createReducer(undefined, { [CatsGetSuccess.type]: () => action.payload, }) const reducerCats = combineReducers({ data: reducerCatsData, loading: reducerCatsLoading) })
Let's look at the pile of factories for selectors again.
I threw out makeSelectorCatsError
because it is no longer needed, as we found out in the previous chapter.
const makeSelectorCatsData = () => createSelector( (state) => state.cats.data, (cats) => cats, ) const makeSelectorCatsLoading = () => createSelector( (state) => state.cats.loading, (loading) => loading, )
And why do we need memorized selectors here? What exactly are we trying to memorize? Accessing an object's field by key, which is what happens here, is O (1). We can use ordinary non-memoisized functions. Use memoization only when you want to change the data from the store before giving it to the component.
const selectorCatsData = (state) => state.cats.data const selectorCatsLoading = (state) => state.cats.loading
Memoization makes sense if you calculate the result on the fly. For the example below, let's imagine that each cat is an object with a name
field, and we want to get a string containing the names of all cats.
const makeSelectorCatNames = () => createSelector( (state) => state.cats.data, (cats) => cats.data.reduce((accum, { name }) => `${accum} ${name}`, ''), )
Let's look at what we started again:
import { createSelector } from 'reselect' const actionTypeCatsGetInit = 'CATS_GET_INIT' const actionTypeCatsGetSuccess = 'CATS_GET_SUCCESS' const actionTypeCatsGetError = 'CATS_GET_ERROR' const actionCatsGetInit = () => ({ type: actionTypeCatsGetInit }) const actionCatsGetSuccess = () => ({ type: actionTypeCatsGetSuccess }) const actionCatsGetError = () => ({ type: actionTypeCatsGetError }) const reducerCatsInitialState = { error: undefined, data: undefined, loading: false, } const reducerCats = (state = reducerCatsInitialState, action) => { switch (action.type) { case actionTypeCatsGetInit: return { ...state, loading: true, } case actionCatsGetSuccess: return { error: undefined, data: action.payload, loading: false, } case actionCatsGetError: return { ...data, error: action.payload, loading: false, } default: return state } } const makeSelectorCatsData = () => createSelector( (state) => state.cats.data, (cats) => cats, ) const makeSelectorCatsLoading = () => createSelector( (state) => state.cats.loading, (loading) => loading, ) const makeSelectorCatsError = () => createSelector( (state) => state.cats.error, (error) => error, )
And what came to:
class CatsGetInit extends ActionStandard {} class CatsGetSuccess extends ActionStandard {} class CatsGetError extends ActionStandard {} const reducerCatsLoading = createReducer( false, reducerLoadingMap(CatsGetInit, CatsGetSuccess, CatsGetError), ) const reducerCatsData = createReducer(undefined, { [CatsGetSuccess.type]: () => action.payload, }) const reducerCats = combineReducers({ data: reducerCatsData, loading: reducerCatsLoading) }) const selectorCatsData = (state) => state.cats.data const selectorCatsLoading = (state) => state.cats.loading
I hope you did not waste your time, and the article was just a little useful to you. As I said at the very beginning, please strongly kick and criticize. We will learn to code better together.
Source: https://habr.com/ru/post/435322/