Another Redux Boilerplate Reduction Guide (NGRX)


    What are we talking about?


    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!


    Boilerplate, come out


    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:


    • Action factories are unique in their own right, but we still use action types.
    • As we add new entities, we continue to duplicate the same logic for setting the flag loading. The data that we store in data, and their form may vary significantly from request to request, but the loading indicator (flag loading) will still be the same.
    • Execution time switch- 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 caseit is necessary to write a couple of extra lines of serving code for each one, and that one switchcannot be easily and beautifully divided into several.
    • Do we really need to store the error state for each entity separately?
    • Selectors are cool. Memoizirovanny selectors - cool double. They give us an abstraction over our story, so that later we do not have to redo half of the application when changing the form thereof. We will simply change the selector itself. What does not please the eye is a set of primitive factories, which are needed only because of the peculiarities of the work of memoization in reselct .

    Method 1: Getting rid of action types


    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 switchby type?


    classCatsGetInit{}
    classCatsGetSuccess{
      constructor(responseData) {
        this.payload = responseData
      }
    }
    classCatsGetError{
      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 design as switchto action.constructorwork. 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 field type( 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 typethat will return the name of this class.


    classCatsGetInit{
      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.


    classCatsGetInit{
      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. Reduce the copy-paste to the minimum and add one more condition: if the action is an error, then it payloadshould be of type Error.


    classActionStandard{
      get static type () {
        return`prefix/${this.name}`
      }
      constructor(payload) {
        this.type = this.constructor.type
        this.payload = payload
        this.error = payload instanceofError
      }
    }
    classCatsGetInitextendsActionStandard{}
    classCatsGetSuccessextendsActionStandard{}
    classCatsGetErrorextendsActionStandard{}
    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.


    classActionStandard{
      get static type () {
        return`prefix/${this.name}`
      }
      constructor(payload) {
        return {
          type: this.constructor.type,
          payload,
          error: payload instanceofError
        }
      }
    }
    classCatsGetInitextendsActionStandard{}
    classCatsGetSuccessextendsActionStandard{}
    classCatsGetErrorextendsActionStandard{}
    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 class ActionStandardflavored with generics for TypeScript needs. Works with both TypeScript and JavaScript.


    Method 2: We are not afraid to use combineReducers


    The idea is simple to disgrace: use combineReducers not only for top-level reduction gears, 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:
          returntruecase actionSuccess.type:
          returnfalsecase actionError.type:
          returnfalse
      }
    }
    classCatsGetInitextendsActionStandard{}
    classCatsGetSuccessextendsActionStandard{}
    classCatsGetErrorextendsActionStandard{}
    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,
    })

    Method 3: Get rid of the switch


    And again, an extremely simple idea: instead of switch-caseusing 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,
      })
    classCatsGetInitextendsActionStandard{}
    classCatsGetSuccessextendsActionStandard{}
    classCatsGetErrorextendsActionStandard{}
    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 refactor a bit reducerLoading. Now, knowing about the mapy (objects) for reducers, we can return this very mapy from reducerLoading, instead of returning the 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,
    })
    classCatsGetInitextendsActionStandard{}
    classCatsGetSuccessextendsActionStandard{}
    classCatsGetErrorextendsActionStandard{}
    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 a lot of projects using switch-case. Based on the code from the official documentation, Mr. Moshe wrote down a library for us createReducer.


    Method 4: Use the global error handler


    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:


    classGlobalErrorInitextendsActionStandard{}
    classGlobalErrorClearextendsActionStandard{}
    const reducerError = createReducer(undefined, {
      [GlobalErrorInit.type]: (state, action) => action.payload,
      [GlobalErrorClear.type]: (state, action) =>undefined,
    })

    Then in our side effect we will send an action ErrorInitin a block 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 field errorin our kitty store and use it CatsGetErroronly to switch the flag loading.


    classCatsGetInitextendsActionStandard{}
    classCatsGetSuccessextendsActionStandard{}
    classCatsGetErrorextendsActionStandard{}
    const reducerCatsLoading = createReducer(
      false,
      reducerLoadingMap(CatsGetInit, CatsGetSuccess, CatsGetError),
    )
    const reducerCatsData = createReducer(undefined, {
      [CatsGetSuccess.type]: () => action.payload,
    })
    const reducerCats = combineReducers({
      data: reducerCatsData,
      loading: reducerCatsLoading)
    })

    Method 5: We Think Before Memoization


    Let's look at the pile of factories for selectors again.


    I threw it makeSelectorCatsErrorout 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 field name, 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}`, ''),
      )

    Conclusion


    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:


    classCatsGetInitextendsActionStandard{}
    classCatsGetSuccessextendsActionStandard{}
    classCatsGetErrorextendsActionStandard{}
    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.


    Also popular now: