Очередное руководство по уменьшению бойлерплейта в Redux (NGRX)


    О чем пойдет речь?


    Будем о говорить о нескольких (пяти, если быть конкретным) способах, трюках, кровавых жертвах богу энтерпрайза, которые вроде как должны помочь нам писать более лаконичный и выразительный код в наших Redux (и NGRX!) приложениях. Способы выстраданы потом и кофе. Просьба сильно пинать и критиковать. Будем учиться кодить лучше вместе.


    Честно говоря, мне сначала просто хотелось рассказать миру о своей новой микро-библиотеке (35 строк кода!) flux-action-class, но, глядя на все возрастающее количество возгласов о том, что Хабр скоро станет Твиттером, да и по-большей части с ними соглашаясь, решил попробовать сделать несколько более емкое чтиво. Итак, встречаем 5 способов прокачать ваше Redux приложение!


    Бойлерплейт, выходи


    Рассмотрим типичный пример того, как можно послать AJAX запрос в Redux. Давайте представим, что нам крайне необходим список котиков с сервера.


    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,
      )

    Если вам не совсем понятно, зачем тут нужны фабрики для селекторов, то можете почитать об этом здесь


    Я сознательно не рассматриваю здесь сайд эффекты. Это тема для отдельной статьи полной подросткового гнева и критики в адрес существующей экосистемы :D


    У этого кода можно найти несколько слабых мест:


    • Фабрики экшнов уникальны сами по себе, но мы все еще используем типы экшнов.
    • По мере добавления новых сущностей мы продолжаем дублировать одну и ту же логику для установления флага loading. Данные, которые мы храним в data, и их форма может существенно изменяться от запроса к запросу, но индикатор загрузки (флаг loading) будет все тот же.
    • Время выполнения switch — O(n) (ну, почти). Сам по себе это не очень сильный аргумент, потому Redux, в принципе, не про производительность. Меня больше бесит, что на каждый case надо писать пару лишних строк обслуживающего кода, и что один switch не получится легко и красиво разбить на несколько.
    • А нам действительно надо хранить состояние ошибки для каждой сущности отдельно?
    • Селекторы — это круто. Мемоизованные селекторы — круто вдвойне. Они дают нам абстракцию над нашим стором, дабы потом нам не пришлось переделывать половину приложения при изменении формы оного. Мы просто поменяем сам селектор. Что тут не радует глаз, так это набор примитивных фабрик, которые нужны лишь из-за особенностей работы мемоизации в reselct.

    Способ 1: Избавляемся от экшн типов


    Ну, не совсем. Просто мы заставим JS создавать их за нас.


    Задумаемся на секунду о том, зачем нам вообще нужны типы у экшнов. Что ж, очевидно, чтобы запускать нужную ветку логики в нашем редьюсере и соответствующим образом менять состояние приложения. Настоящий вопрос в том, обязательно ли тип должен быть строкой? А что если бы мы использовали классы и делали switch по типу?


    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
      }
    }

    Все вроде здорово, но есть одна проблема: мы лишились сериализации наших экшнов. Это более не простые объекты, которые мы можем конвертировать в строку и обратно. Теперь мы полагаемся на то, что у каждого экшна есть свой уникальный прототип, что, собственно, и позволяет такой конструкции, как switch по action.constructor, работать. Знаете, а мне очень нравится идея сериализации моих экшнов в строку и отправка их вместе с баг репортом, и я от нее отказываться не готов.


    Итак, у каждого экшна должно быть поле type (здесь можно посмотреть, что еще должно быть у каждого уважающего себя экшна). К счастью, у каждого класса есть имя, которое вроде как строка. Давайте добавим каждому классу геттер type, который будет возвращать имя этого класса.


    class CatsGetInit {
      constructor() {
        this.type = this.constructor.name
      }
    }
    const reducerCats = (state, action) => {
      switch (action.type) {
        case CatsGetInit.name:
          return {
            ...state,
            loading: true,
          }
        //...
      }
    }

    Это даже работает, но хотелось бы еще каждому типу прилепить префикс, как предлагает мистер Эрик в ducks-modular-redux (рекомендую глянуть на форк re-ducks, который еще круче, как по мне). Для того, чтобы добавить префикс, нам придется перестать использовать имя класса напрямую, а добавить еще один геттер. Теперь уже статический.


    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,
          }
        //...
      }
    }

    Давайте все это дело немного причешем. Сократим до минимума copy-paste и добавим еще одно условие: если экшн представляет ошибку, то его payload должен быть типа 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
      }
    }

    На данном этапе этот код отлично работает с NGRX, но Redux такое прожевать не способен. Он ругается на то, что экшны должны быть простыми объектами. К счастью, JS позволяет нам возвращать почти что угодно из конструтора, а нам на самом деле не очень-то и нужна цепочка прототипов после создания экшна.


    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
      }
    }

    На основе вышеизложенных размышлений была написана микро-библиотека flux-action-class. Там есть тесты, 100% покрытие кода тестами и почти тот же класс ActionStandard приправленный дженериками для нужд TypeScript. Работает как с TypeScript, так и с JavaScript.


    Способ 2: Не боимся использовать combineReducers


    Идея проста до безобразия: использовать combineReducers не только для редьюсеров верхнего уровня, но и для дальнейшего разбиения логики и создания отдельного редьюсера для 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,
    })

    Способ 3: Избавимся от switch


    И снова предельно простая идея: вместо switch-case использовать объект, из которого выбирать нужное поле по ключу. Доступ к полю объекта по ключу — O(1), да и выглядит порядком чище по моему скромному мнению.


    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,
    })

    Давайте немного отрефакторим reducerLoading. Теперь, зная про мапы (объекты) для редьюсеров, мы можем вернуть эту самую мапу из reducerLoading, вместо того, чтобы вернуть целый редьюсер. Потенциально, это открывает неограниченный простор для расширения функционала.


    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,
    })

    Официальная документация на Redux тоже рассказывает про этот подход, однако, по какой-то неведомой причине я продолжаю видеть множество проектов, использующих switch-case. На основе кода из официальной документации мистер Моше запилил для нас библиотеку для createReducer.


    Способ 4: Используем глобальный обработчик ошибок


    Нам совершенно не обязательно держать ошибку для каждой сущности отдельно. В большинстве случаев мы просто хотим показать диаложку. Одну и ту же диаложку с динамическим текстом для всех сущностей.


    Создадим глобальный обработчик ошибок. В самом простом случае он может выглядеть так:


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

    Затем в нашем сайд эффекте будем отправлять экшн ErrorInit в блоке catch. Это может выглядеть как-то так при использовании 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))
      }
    }

    Теперь мы можем избавиться от поля error в нашем сторе для котиков и использовать CatsGetError лишь затем, чтобы переключать флаг loading.


    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)
    })

    Способ 5: Думаем перед мемоизацией


    Посмотрим на нагромождение фабрик для селекторов еще раз.


    Я выкинул makeSelectorCatsError, потому что он больше не нужен, как мы выяснили в предыдущей главе.


    const makeSelectorCatsData = () =>
      createSelector(
        (state) => state.cats.data,
        (cats) => cats,
      )
    const makeSelectorCatsLoading = () =>
      createSelector(
        (state) => state.cats.loading,
        (loading) => loading,
      )

    А зачем нам тут мемоизованные селекторы? Что конкретно мы пытаемся мемоизовать? Доступ к полю объекта по ключу, что здесь и происходит, — O(1). Мы можем использовать обычные немемоизованные функции. Используйте мемоизацию только тогда, когда вы хотите изменить данные из стора перед тем как отдать их компоненте.


    const selectorCatsData = (state) => state.cats.data
    const selectorCatsLoading = (state) => state.cats.loading

    Мемоизация имеет смысл в случае вычисления результата на лету. Для примера ниже давайте представим, что каждый котик — это объект с полем name, и мы хотим получить строку, содержащую имена всех котиков.


    const makeSelectorCatNames = () =>
      createSelector(
        (state) => state.cats.data,
        (cats) => cats.data.reduce((accum, { name }) => `${accum} ${name}`, ''),
      )

    Вывод


    Посмотрим еще раз с чего мы начали:


    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,
      )

    И к чему пришли:


    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

    Надеюсь, вы не потратили время зря, и статья была вам хоть чуточку полезна. Как я говорил в самом начале, просьба сильно пинать и критиковать. Будем учиться кодить лучше вместе.

    Поделиться публикацией

    Комментарии 28

      0
      Более красивый и лаконичный код получается писать если использовать mobx.
        –1

        Для начала скажу, что MobX в проде не юзал пока, дабы был контекст. Теперь по делу. Мне нравится подход MobX лаконичностью, но там нет святого грааля Redux в моем понимании — возможности сделать replay. В идеале, хочу держать в свое сторе как можно более полное состояние приложения, чтобы потом при ошибке у пользователя получить набор экшнов, который к этому привел, и с точностью воспроизвести ошибку. Плюс самому дебажить по диффам между состояними стора и списку экшнов — одно удовольствие.
        Я к курсе, что есть mobx-state-tree. Глубоко его не изучал пока что, но собираюсь посидеть и поковырять. Есть опасения насчет производительности в некоторых случаях. Как я понимаю, для того чтобы заставить императивные операции, которые мутируют стейт, возвращать новое иммутабельное состояние, они должны были запилить некий proxy наподобие immer. Есть подозрение, что в некоторых случаях редьюер сделанный руками будет эффективнее, чем иммутабельность через прокси в mobx-state-tree.

          +2
          В идеале, хочу держать в свое сторе как можно более полное состояние приложения, чтобы потом при ошибке у пользователя получить набор экшнов, который к этому привел, и с точностью воспроизвести ошибку.

          Часто вижу такие комментарии и понять не могу, как вы код пишете, что не понимаете, что происходит в приложении при ошибке. Вам в консоль валится полный трейс откуда ноги растут, а вы без всяких чудесных расширений для хрома, не понимаете что происходит.
            0
            Как по мне, то разница между простым логгированием и экшнами в том, что логгирование позволяет попробовать догадаться о состоянии, что там происходило, а экшны дают точную реплику этого состояния. В общем, для маленьких приложений, вроде как, и не актуально. А в средних и больших, вроде как, и полезно.
            +2
            1. mobx-state-tree не нуждается в создании Proxy перед каждым изменением, как immer. Потому что все поля и методы моделей определяются статически, во время разработки.

            2. Асимптотически mobx и mobx-state-tree быстрее redux за счет реактивности. Пример: список комментариев к этой статье. При изменении одного комментария, для redux количество операций это O(N), где N — кол-во connected компонентов. А для mobx это O(1) — компонент, который отображает единственный измененный комментарий. Т.е. при небольших изменениях mobx быстрее.

            Зато, если мы меняем сразу много данных, то redux может выиграть за счет использования plain объектов. Потому что рендеринг каждого реактивного объекта mobx — это не только чтение данных, но и обновление графа зависимостей. В общем, it depends =)

              0
              1. Спасибо. Посмотрел. Ваша правда.
              2. Вы ведь немного лукавите насчет O(n), правда? :) Я к тому, что `listener`, конечно, будет вызываться при каждом обновлении стора, но использование PureComponent позволит избежать ререндера.
                +1
                Разумеется, я имею в виду N вызовов mapStateToProps. Да, reselect помогает не рендерить много. Но, часто, написать корректную мемоизацию становится нетривиальной задачей.
                0

                Справедливости ради замечу, что в React-redux v6 поменяли логику подписки и теперь есть только одна подписка на store — в провайдере. В релизном посте есть немного деталей.


                Кроме того, планируется использование экспериментальных observedBits, чтобы не вызывать лишних потребителей контекста.


                Утверждать, что React-redux обгонит MobX не буду, но положительные улучшения все равно есть.

                  0

                  Только вот че-то они пишут, что все как раз наоборот:


                  Both an earlier WIP v6 iteration and the final version of the v6 PR were slower than v5, in all benchmark scenarios
                +1

                И еще про mobx-state-tree. Он очень облегчает жизнь, если нужна сложная логика при работе с реляционными или графовыми данными. В redux в таком случае приходится работать с их нормализованным представлением. Но если в reducer это удобно, то в компонентах, селекторах и эффектах —это боль =(


                И тут нет хорошего решения:


                • Либо мы делаем большинство компонентов connected и работаем с нормализованными данными. Это порождает тонны бойлерплейта и ударяет по производительности.
                • Либо используем схемы для денормализации (redux-orm, normalizr.denormalize). Что уже несколько удобнее, но еще сильнее убивает производительность.
                  0
                  Можете пример какой подсказать или статью где есть работа с графовыми данными в MST и аналогично в Redux?
                  Сам какое-то время работал с redux-orm. Увидев, выпученные от удивления глаза новых членов команды, выплил ее к чертям. Сейчас для хранения списков, если нет глубокой вложенности, предпочитаю `Map`, т.к. оно и O(1) по ключу дает, но при этом и порядок вставки запоминает.
                    +1

                    Честно говоря, не видел статей с таким сравнением. Пишу из личного опыта: по работе пришлось писать редактор объектного графа, в котором валидаторы сущностей залезали на два-три уровня вглубь по связям. На голом Redux быстро запутался с мемоизацией. redux-orm на сколько я помню, работает только с плоскими реляционными таблицами. Т.е. не может вот так:


                    type User = {
                      id: number;
                      addresses: [{
                        region: Region; // ссылка на другую сущщность
                        street: string;
                        // ...
                      }];
                      // ...
                    };

                    А вот MST с этим справился на ура.


                    По функциональности createSelector из reselect похож на computed из mobx. Но есть отличия в поведении. Для примера: хотим получить объект комментария с включенным автором. На reselect будет что-то вроде:


                    сconst makeGetComment = () => createSelector(
                      [state => state.entities.comments, state => state.entities.users,
                      (state, props) => props.id],
                      (comments, users, id) => ({
                        ...comments[id],
                        author: users[comments[id].author]
                      })
                    )

                    Причем, этот селектор инвалидируется при изменении любого пользователя или коммента. И PureComponent не поможет.


                    А в mobx этого всего вообще не надо: у нас уже есть простая ссылка между объектами comment.author. И все зависимые вычисления (computed, rendering) будут выполнены только при изменении конкретного автора или конкретного коммента.


                    Но часто не мы решаем, какие технологии использовать, а они достаются нам по наследству. И Redux будет с нами еще долгие годы. Поэтому я сейчас пишу велосипед, который выполняет денормализацию со structural sharing. Как доделаю — будет статья на Хабр. Там и попробую сравнить производительность с MST.

                      +1
                      А что если так?
                      ```javascript
                      const makeGetComment = () => createSelector(
                      [
                      (state, props) => state.entities.comments[props.id],
                      (state, props) => state.entities.users[
                      state.entities.comments[props.id].author
                      ]
                      ],
                      (comment, author) => ({
                      ...comment,
                      author
                      })
                      )
                      ```
                      Выглядит, конечно, ужасно, но работать по идее должно без лишней инвалидации селектора. Как раз такой пример по идее и решается redux-orm. Хотя, честно говоря, не так уж часто мне приходилось сталкиваться с необходимостью делать подобную денормализацию. Обычно, проще сервер попросить вернуть данные в нужном формате. Иначе если запросить список юзеров и список комментов отдельно, то можно получить слишком много данных (не все комменты нужно отображать, не для всех юзеров и т.д.). А если предположить, что список комментов уже был получен ранее, то можно нарваться на проблемы кеширования. Так что подобные проблемы становятся действительно актуальны чаще всего тогда, когда мы хотим с локальным кешем работать и лениво го с серваком синхронизировать.
                      Так или иначе, ваш посыл понятен. Спасибо. Надо присмотреться к MST.
                        0

                        Да, так будет работать. А вот так уже нет:


                        const makeGetPost = () => createSelector(
                          [
                            (state, props) => state.entities.posts[props.id],
                            (state, props) => state.entities.posts[props.id].comments
                              .map(id => state.entities.comments[id])
                          ],
                          (post, comments) => ({
                            ...post,
                            comments
                          })
                        )

                        Поэтому я и говорю, что запутался в селекторах.


                        Посмотрел, как работает redux-orm. Он явно сверяет все entity, затронутые селектором, с их предыдущими версиями. И тут уже неизвестно, что быстрее — такое сравнение или VDOM reconciliation.


                        не так уж часто мне приходилось сталкиваться с необходимостью делать подобную денормализацию

                        Да, не часто. Но если встречается, то мало никому не покажется =)


                        Пример из жизни:


                        type Region = { id, name, ... }
                        type RegionalTariff = { id, regions: Region[], ... }
                        type FederalTariff = { id, regionalTariffs: RegionalTariff[], ... }

                        И правило валидации: списки регионов у разных региональных тарифов не должны пересекаться в рамках федерального тарифа.

              –2
              Рассмотрим типичный пример того, как можно послать AJAX запрос в Redux. Давайте представим, что нам крайне необходим список котиков с сервера.

              Далее идёт 50 строчек бойлерплейта, которые собственно AJAX запроса даже не выполняют.


              И к чему пришли:

              16 строчек бойлерплейта, которые опять же ничерта не делают.


              Смотрите, буквально вчера я написал 13 строчек, которые строго типизированно загружают все страницы списка задач проекта с гитхаба: https://github.com/eigenmethod/mol/blob/master/app/issues/issues.view.ts#L56
              И как это выглядит в действии: https://mol.js.org/app/issues/#projects=reduxjs%2Fredux


              А ваших котиков можно было бы загрузить вообще в 3 строки:


              cats() {
                  return $mol_http.resource( 'cats.json' ).json() as string[]
              }

              Именно загрузить. Асинхронно. С показом индикатора ожидания и текста ошибки вместо списка котиков. А вы всё обёртки вокруг Redux (и NGRX!) делаете.

                0

                Дык оно и в Реакте можно без Redux. Также в одну строчку, используя браузерный fetch API. Как по мне, так основная проблема mol — это его популярность, точнее ее отсутствие. Когда я выбираю фреймворк/библиотеку для нового проекта, одним из важных аргументов является популярность. Просто потому что бизнес может захотеть расширить проект, привести новых людей в команду. При использовании популярных решений сделать это будет в разы проще.

                  +1
                  Дык оно и в Реакте можно без Redux.

                  Можно, конечно, при чём тут Реакт?


                  Также в одну строчку, используя браузерный fetch API.

                  Этак разве что без индикатора загрузки. В Реакте можно через SuspenseAPI достигнуть одной строчки.


                  основная проблема mol

                  Да не в том суть, а в том, что с Редаксом кода получается вагон, архитектура вывернутая кишками наружу. Зато таймтревел. Будто иначе его не реализовать.


                  привести новых людей в команду. При использовании популярных решений сделать это будет в разы проще

                  Если бы. Приходит Реактовод на Ангулярный проект. Сколько потребуется на его переобучение? Да даже если Реакт. Одни с Редаксом работали, другие без — это опять же небо и земля. Толковых разработчиков и так дефицит, так что отсеивать по опыту работы с фреймворком — контрпродуктивно.

                  +3
                  16 строчек бойлерплейта, которые опять же ничерта не делают.

                  Я бы еще заметил, что это 16 строчек бойлерплейта, лишенные того, что автор счёл «общим кодом» (ActionStandard и далее по списку). Это, мягко говоря, нечестное сравнение.

                  ЗЫ: Вот как раз из-за бойлерплейта не хочу браться за Redux вообще. И авторская статья тут не только не убеждает в обратном, но даже и наоборот. Когда у нас некие несложные но многочисленные типовые интерфейсы и квадратно-гнездовой подход — Redux будет даже, наверное, приятен: слепил весь бойлерплейт на все типичные случаи и сиди дальше штампуй формочки. В противном случае необходимость сидеть писать структурные нагромождения по любому, в том числе и любому очень банальному поводу — вымораживает.
                    0

                    Это верно подмечено. Сделано было для пущего драматического эффекта. Но на самом деле ActionStandard вынесен в библиотеку, createReducer вынесен в библиотеку, так что их и не придется писать. Написать надо будет разве что reducerLoadingMap. Но пара лишних строчек на reducerLoadingMap с лихвой окупится, когда будет работа не с одной сущностью, а с десятью.

                      0

                      Я бы даже сказал, что не из-за бойлерплейта как такового. А из-за того, что в каждом конкретном проекте все эти проблемы решаются по-разному с помощью десятка библиотек и велосипедов разной степени свежести, популярности и упоротости.


                      И вот вроде все тот же React + Redux. А вникать приходится каждый раз заново. Часто думаешь — пусть уж лучше будет бойлерплейт.

                        0
                        Это, по-моему, в принципе, самая большая проблема экосистемы реакта — отсутствие стандартов, которые бы поддерживались профессионалами на зарплате, а не энтузиастами.
                        Поэтому лично мне хочется на данном этапе иметь набор библиотек, которые помогут побороть бойлерплейт, но при этом будут оставаться максимально простыми, с как можно меньшим количеством проприетарного API.
                          +1
                          Это, по-моему, в принципе, самая большая проблема экосистемы реакта — отсутствие стандартов, которые бы поддерживались профессионалами на зарплате, а не энтузиастами.

                          Скажем так, поддержка стандартов людьми на зарплате — вовсе не гарантирует того, что итог будет хорошим. Мы все видели ангуляр, в конце концов.
                          Итог обычно хорош как раз тогда, когда вот вселенский разброд и шатание в конце концов порождает понимание, как делать стоит, а как — не стоит. Боль от необходимости поддерживать то, что было написано на кривых старых либах — это не отменит, но зато будущее будет скорее светлым, чем наоборот.

                          Так что будем надеяться, что с течением времени хорошие практики тут постепенно сложатся. Не обязательно для конкретно реакта кстати, реакт хорош, но не безпроблемен, и весь мир на нём не сошелся.
                    0

                    Пойдем еще дальше:


                    @connectResources((state, props) => ({
                      users: usersResource.list(state, { query: props.query }),
                    })
                    class UsersList extends React.Component {
                      propTypes: {
                        users: PropTypes.shape({
                          fetching: PropTypes.boolean.isRequired,
                          failed: PropTypes.boolean.isRequired,
                          data: PropTypes.array,
                        })
                      }
                    }

                    Все это разворачивается в классический бойлерплейт. Данные просто подключаются к компоненту: если они уже есть в Redux store, то приходят оттуда, если нет – то сначала загружаются в store из сети, а потом прокидываются в компонент. По умолчанию данные всегда обновляются из сети на componentDidMount.

                      –1

                      Не только можно, но даже нужно! Единственное, я для себя пришел к выводу (на данный момент), что подобные обертки надо писать каждый раз заново для каждого проекта. Все проекты немного разные с разными требованиями, поэтому я пока предпочитаю иметь в наличии множество небольших кубиков (таких как flux-action-class, набор стандартных редьюсеров для работы со списками и т.д.) и собирать из них конечный велосипед под нужды проекта. В конечном итоге, да, стремлюсь собрать заниженный велик с турбированным движком и тонировкой по периметру, чтобы получалось что-то похожее на то, что вы предложили.

                        +1

                        Главное не пытаться запилить абстрактно, а вытачивать в бою.

                      +3
                      Могу быть неправ. Но, reselect — только и нужен для мемоизации сложных преобразований, если она не нужна — можно писать свои селекторы с О(1) в виде функций. Итог: понимай зачем нужны инструменты которые используешь.

                      А вобще, посмотрите пожалуйста на библиотеку redux-act. Она позволяет строить экшены без голых {type: "...."} конструкций и разбивать редьюсер на красивые функции-обработчики с ресолвом через обьект вместо switch. Почти все что вы описали в статье уже реализовано.
                        0

                        Мысль как раз в том и была, что не стоит бездумно использовать все, что под руку попадется. Пример с селекторами привел лишь потому, что в документации к самому NGRX предлагается использовать мемоизацию на каждый чих.


                        Redux-act — да, клевая штука. Разве что их createAction с TypeScript дружит не так хорошо, как flux-action-class, а createReducer немного перегружен на мой взгляд по сравнению с redux-create-reducer, потому что парень хотел ES5 поддержать

                          0

                          Вы просто не умеете готовить redux-act :)


                          import { createReducer, createAction } from 'redux-act'
                          import axios from 'axios'
                          import { API } from 'src/constants'
                          
                          const NS = 'POST__'
                          
                          const initialState = {
                            item: {},
                            isLoading: false,
                          }
                          
                          export type IState = typeof initialState
                          
                          const reducer = createReducer<IState>({}, initialState)
                          
                          const readItemRequest = createAction(`${NS}READ_ITEM_REQUEST`)
                          reducer.on(readItemRequest, (state) => ({
                            ...state,
                            isLoading: true,
                          }))
                          
                          const readItemSuccess = createAction<IState['item']>(`${NS}READ_ITEM_SUCCESS`)
                          reducer.on(readItemSuccess, (state, item) => ({
                            ...state,
                            item,
                            isLoading: false,
                          }))
                          
                          const readItemFailure = createAction(`${NS}READ_ITEM_FAILURE`)
                          reducer.on(readItemFailure, (state) => ({
                            ...state,
                            isLoading: false,
                          }))
                          
                          export const readPost = (id: number): Store.IReduxThunkCallBack => (dispatch) => {
                            dispatch(readItemRequest())
                            return axios
                              .get(`${API}posts/${id}/`)
                              .then(({ data: item }) => {
                                dispatch(readItemSuccess(item))
                              })
                              .catch((error) => {
                                dispatch(readItemFailure())
                                return Promise.reject(error)
                              })
                          }
                          
                          export default reducer

                          А общая обработка ошибок построена на соглашении, что все функции redux-thunk возвращают Promise:


                          export const dispatch = (action) => {
                            if (isPromise(action)) {
                              return store.dispatch(action).catch((error) => {
                                if (error) {
                                  console.error(error)
                                }
                                return Promise.reject(error)
                              })
                            }
                            return store.dispatch(action)
                          }

                          Компоненты должны дергать только эту обертку для store.dispatch() (вместо dispatch(), который приходит параметром в mapDispatchToProps() получаемого из connect()).


                          Конечно, большой соблазн обобщить шаблонный код: _REQUEST > вызов axios > _SUCCESS > _FAILURE, видел несколько реализаций, но теряется читабельность, на мой вкус.


                          Я сейчас в процессе очередной попытки получить "аленький цветочек", реализую шаблон проекта на стеке Create React App + Type Script + Antd Design Components + Styled Components.

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое