Привет, Хабр! Представляю вашему вниманию перевод статьи "Redesigning Redux" автора Shawn McKay.

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

  • Вам действительно необходима библиотека для управления состоянием?
  • Заслужил ли Redux свою популярность? Почему или почему нет?
  • Можем ли мы придумать лучшее решение? Если да, то какое?

Необходима ли библиотека для управления состоянием?


Front-end разработчик не тот, кто попросту передвигает пиксели из стороны в сторону; истинное искусство в знании где хранить состояние. Это кажется сложным только на первый взгляд.

Давайте рассмотрим возможности, которые предоставляет нам React:

image


1. Состояние компонента (Component State)


Состояние хранится внутри компонента. В React мы обновляем state через setState().

2. Относительное состояние (Relative State)


Состояние переданное от родителя потомку. В React передаем props как свойство компонента потомка.

3. Переданное состояние (Provided State)


Состояние хранится в поставщике (provider), и доступно любому компоненту (consumer), расположенному ниже по дереву. Context API в React.

View хранит большую часть состояния. Но что делать с остальным кодом, который отражает основные данные и логику?

Размещение всего кода внутри компонентов может привести к низкому разделению ответственности: растет зависимость от view-библиотек, усложняется тестирование такого кода, но самое страшное: приходится регулярно менять способ хранения состояния.

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

4. Внешнее состояние (External State)


Состояние может находиться отдельно от компонентов, которые синхронно «свяжуться» с ним при помощи provider/consumer паттерна. Вероятнее всего самой большой популярностью, среди библиотек для управления состоянием, пользуется Redux. В течении последних двух лет она получила большую известность среди разработчиков. Так в чем причина такой любви к одной библиотеке?

Redux более производительный? Нет. На самом деле, работа приложения немного замедляется с каждым новым действием, которое должно быть обработано.

Redux прост в применении? Конечно нет.

Простым был бы нативный javascript:

image

Так почему каждый не может использовать global.state = {}?

Почему Redux?


Под капотом, Redux аналогичен глобальному объекту TJ, только обернут рядом утилит.
image

В Redux можно непосредственно изменять состояние, путем передачи (dispatch) действий (action) через указанные инструменты.

Библиотека включает два вида обработчиков действий: middleware & subscriptions. Middleware — это функции, которые перехватывают действия. Включают такие инструменты как «logger», «devtools» или «syncWithServer». Subscriptions — это функции, используемые для отправки изменений компонентам.

Наконец, редьюсеры (reducer) — это функции, которые изменяют состояние и делят его на мелкие, модульные и управляемые части.

Вероятнее всего, Redux более применим для хранения состояния, чем глобальный объект.

Думайте о Redux как о глобальном объекте с расширенными возможностями и упрощенным способом «преобразования» состояния.

Настолько ли сложен Redux?


Да. Есть несколько неоспоримых признаков, что необходимо улучшить API; можно сделать вывод при помощи следующего уравнения:

Считаем, что time_saved подразумевает время затраченное на разработку собственного решения, а time_invested равняется часам потраченным на чтение документации, прохождение обучающих курсов и изучение новых понятий.

Redux, в принципе, простая и небольшая библиотека с крутой кривой обучения. На каждого разработчика, который овладел и извлек выгоду из Redux, погрузившись в функциональное программирование, найдется другой потенциальный разработчик, запутавшийся и думающий «это все не для меня, я возвращаюсь к jQuery».

Вы не должны разбираться что такое «comonad», используя jQuery, и не обязаны понимать функциональную композицию, чтобы справиться с управлением состоянием.

Цель любой библиотеки: сделать сложное простым при помощи абстракции.

Я не намерен высмеивать Дэна Абрамова. Redux стал популярным на слишком ранней стадии своего развития.

  • Как внести изменения в библиотеку, которую используют миллионы разработчиков?
  • Как вы оправдаете критические изменения, которые повлияют на проекты во всем мире?

Вы не сможете. Но предоставив расширенную документацию, обучающие видео и помощь комьюнити, вы окажете неоценимую помощь. У Дэна Абрамова получилось это.

А может есть другой путь?

Совершенствуем Redux


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

1. Настройка


Предлагаю посмотреть на первоначальную настройку Redux-приложения (левый скрин).

Много разработчиков, сразу после первого шага, остановились в недоумении. Что такое thunk? compose? Способна ли функция на такое?

Считается, что Redux основан на конфигурации над композицией. Настройка должна быть похожа на пример справа.

2. Упрощаем редьюсеры


Редьюсеры в Redux могут использовать switch-конструкции далекие от тех, которые мы привыкли использовать.

Учитывая, что редьюсеры находят соответствие по типу действия, мы можем сделать каждый редьюсер чистой (pure) функцией, принимающей состояние и действие. Можно сократить действие и передавать только состояние и данные.

3. Async/Await без Thunk


Thunk широко используется для создания асинхронных действий в Redux. Во многих отношениях, thunk больше похож на умный хак, чем на официально рекомендуемое решение. Как это работает:

  1. Вы передаете действие, уже как функцию, а не объект.
  2. Thunk проверяет каждое действие, что оно является функцией.
  3. Если все сходится, thunk вызывает эту функцию и передает в нее некоторые методы стора: dispatch и getState.

Серьезно? Следует ли таким образом типизировать простые действия как объект, функцию или даже Promise?

Возможно ли использовать async/await, как в примере справа?

4. Два вида действий


Задумайтесь, ведь действительно есть два вида действий:

  1. Reducer action: запускает редьюсер и изменяет состояние.
  2. Effect action: запускает асинхронное действие. Может вызвать reducer action, но асинхронная функция не способна напрямую изменить состояние.

Умение различать виды действий принесет больше пользы, чем использование «санков».

5. Никаких больше переменных хранящих тип действия


Почему принято разделять генераторы действий (action creators) и редьюсеры? Может ли один существовать без другого? Как изменить один не изменяя другой?

Генераторы действий и редьюсеры две стороны одной медали.

const ACTION_ONE = 'ACTIONE_ONE' — это лишний побочный эффект разделения генераторов действий и редьюсеров. Обращайтесь с ними как с единым целом и отпадет потребность в крупных файлах с экспортом типов.

6. Редьюсеры — это генераторы действий


Объединяйте элементы Redux по их назначению, и вы получите простой шаблон.

В итоге, следуя этому сценарию, редьюсер может стать генератором действий.

Используйте соглашение об именах, и следующие пункты будут довольно предсказуемы:

  1. Если редьюсер получил название «increment», тогда тип будет «increment». Даже лучше, обозначим как «count/increment».
  2. Каждое действие передает данные через «payload».


Теперь при помощи count.increment мы можем создать генератор действий напрямую из редьюсера.

Хорошие новости: мы можем улучшить Redux


Эти проблемные части мотивировали на создание Rematch.

Rematch служит оберткой вокруг Redux, предоставляя разработчикам упрощенное API.

Вот полный пример кода с Rematch:

Я использовал Rematch в production последние несколько месяцев. И что я думаю:

Я никогда не тратил так мало времени на управление состоянием.


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

Опробуйте Rematch и выясните, нравится он вам или нет.
Поставьте звезду, чтобы позволить узнать о нас другим.

Комментарии (25)


  1. apapacy
    15.04.2018 21:25

    теперь при помощи count.increment мы можем создать генератор действий напрямую из редьюсера.


    Идея прямо скажем противоположная тому что предлагает redux. Скорее всего даже вредная.

    На мой взгляд основная проблема redux имеет в основе основную проблему react а именно синхронность. В результате кому не знакома такая ситуация. Мы рендерим компонент. При этом в событии жизненного цикла запрашиваем данные с сервера. Далее компонент рендерится с пустым стором (для того чтобы это не вылетало в ошибку нужно дополнительно всяких условий накидать или синхронно инициализировать стор пустыми значениями. Если кто как делает по-другому то прошу поделиться опытом). Потом по получении данных с сервера происходит повторный рендер.

    Я честно говоря нашел один хук куда внедрил асинхронность — это как ни странно в компонент Link. Это избавило меня от необходимости в этих неестественных проверках стора на непустоту и псевдо-инициализаций стора, а пользователя в наблюдении прелоадеров. См. например вариант приложения real-world realworld-react-universal-hot-iltreezyct.now.sh/author/Apa%20Pacy

    Но в этом году все должно измениться. См. доклад Дэна Абрамова habrahabr.ru/post/350816 с русскими субтитрами. Вобщем-то значение redux (в современном виде) после этого немного становится туманным.


    1. TheShock
      16.04.2018 00:53

      Идея прямо скажем противоположная тому что предлагает redux. Скорее всего даже вредная.

      Мне вот всегда было интересно. Допустим, можно реально генерировать AC из редюсеров (если нам правда хватает этой информации в редюсерах), то в чем вредность такой генерации? Зачем писать дополнительный код, который пишется копипастой, если уже написано достаточно кода и на его базе можно сгенерировать всё необходимое? Так почему писать меньше ненужного кода — вредно?


      1. apapacy
        16.04.2018 01:28

        Не понял что такое АС если можно расшифруйте.

        Генерировать «действия» в редьюсере мне кажется нелогичным.
        Давайте посмотрим что из себя представляет редьюсер. Я честно говоря не в восторге от терминологии redux возможно в этом причина, что многим эта технология не по душе. (Мне тоже кстати была пока я не почитал курс Максима Пацианского см. habrahabr.ru/post/351046 )

        Редьюсер это собственно функция которая вызывается при выполнении каждого действия.
        Она может вернуть тот же самый объект и тогда ни один компонент не будет перерисован. Либо может вернуть новый объект. Тогда все компоненты которые слушают изменение состояния будут обновлены. Таких редьюсеров обычно несколько в одном приложении.

        Предназначение и функция редьюсера это просто вернуть старое или обновленное состояние объекта. Вызов каждого действия вызывает все функции-редьюсеры но «срабатывает» только нужная из них. При этом гарантируется что сналачало инициируется действие. Потом вызываются редбюсеры. Потом опять действие. потом опять редьюсеры и т.д. То есть образуется своеобразная цепь переходов их одного состояние в другое.

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


        1. TheShock
          16.04.2018 01:41

          Не понял что такое АС если можно расшифруйте.

          ActionCreator

          Что если из редьюсера начать вызывать действия? [...] Фактически мы входим в рекурсию при этом

          Простите, не путайте «вызывать действия из редьюсера» и «генерировать ActionCreator на базе редьюсера». Так как вы спутали эти две вещи — остальные ваши слова не имеют смысла. Вот у вас есть типичный экшн:
          function apologize(fromPerson, toPerson, error) {
            return {
              type: 'APOLOGIZE',
              fromPerson,
              toPerson,
              error
            };
          }


          Какой в нем смысл? Такие функции должны стать рутинной работой генератора, а не копипаститься.


          1. apapacy
            16.04.2018 01:55

            Ну как правило действия все же более развернутые. У меня таких практически (то есть совсем) не встречается. Хотя я видел у некоторых и для скорости они прямо диспатчили объектами это — не функциями так примерно
            dispatch({
            type: 'APOLOGIZE',
            fromPerson,
            toPerson,
            error
            };)


            1. TheShock
              16.04.2018 01:57

              А какие у вас? Покажите пример



  1. Druu
    16.04.2018 00:26
    +2

    Можно бесконечно смотреть на три вещи: как горит огонь, как течет вода, и как кто-то совершенствует Redux.


  1. TheShock
    16.04.2018 00:48

    Хорошая попытка уйти от тонны копипасты, хотя она явно противоречит идеологии автора Редакса.

    Один вопрос. Что делать, если, скажем, в эффекте необходимо менять не один стор (не знаю, как еще такой блок назвать), а несколько?

    Кстати, я вот подумал. Можно пойти дальше и убрать редюсеры (они ведь тут остались как ФП ради ФП), изменять стейт в effects, а так как они остались одни — вынести на уровень выше. Получится, внезапно, MobX!

    export class Count {
      state: 0, // initial state
    
      @action increment (delta) {
        this.state += delta
      }
    
      async incrementAsync(delta) {
        await new Promise(resolve => setTimeout(resolve, 1000))
        this.increment(delta);
      }
    }


    1. apapacy
      16.04.2018 01:32

      Насчет копипасты можно поподробнее? Что и куда копипастится в redux? Я просто обычно не делаю этого. Поэтому спрашиваю.


      1. TheShock
        16.04.2018 01:42

        Когда вы создаете самый просто action — сколько раз вы нажимаете ctrl+c/ctrl+v?


      1. VolCh
        16.04.2018 09:03
        +1

        Условно:


        const CALC_ACTION = 'CALC_ACTION';
        
        const calcActionCreator = (a, b, c) => { type: CALC_ACTION, payload :{a, b, c }};
        
        const calc = (arg, a, b, c) => { x: arg.x +a, y: arg.y * b, z: c}
        
        const INITIAL_STATE = {x: 3, y: 4: z: 5 };
        
        const reducer = (state = INITIAL_STATE, action) => {
        // ...
          case SOME_ACTION:
            return {...state, ...calc(state, ...action.payload))};
        // ...
        };
        
        dispatch(calcActionCreator({a: 6, b: 9, c: 10});

        Как по мне, то достаточно много копипасты, и это всё в одном файле, без импортов — с ними ещё больше.


    1. kashey
      16.04.2018 01:47

      Уровень наркомании не достаточен.
      Например redux-restate может взять два стора на вход, и выдать один стор на выход (только для react «части»), а redux-loop может по некому redux action вызвать что-то из context текущего компонента, что может в итоге вызвать dispatch в сторе родительсткого приложения.

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


      1. mayorovp
        16.04.2018 09:11

        Не достаточен кому? В MobX как бы проблемы «соединения сторов» нет by design за отсутствием этих самых сторов.


        1. kashey
          16.04.2018 09:16

          Счастливые вы люди.


    1. parmactep
      16.04.2018 06:22

      Каждый раз когда кто-то начинает говорить о недостатках redux, у меня возникает вопрос — почему не использовать уже mobx, черт возьми.


      1. Staltec
        16.04.2018 12:05
        +1

        Потому что у большинства молодых разработчиков redux головного мозга. Мы когда искали фронтенд-джунов. Разбирали буквально несколько десятков тестовых заданий. На 25 заданий, только один использовал «не Redux». Когда спрашивал «почему?», говорят «посмотрите на вакансии на рынке — везде Redux». При этом у нас стоит условие «dataflow» на ваше усмотрение.

        Мы занимаемся разработкой и поддержкой крупных проектов. В своё время в ужасе убежали с Redux на MobX + наша библиотека github.com/wearevolt/mobx-model которая позволяет держать в памяти модели-сторы с перекрёстными связями. Это позволяет легко загружать и обрабатывать сложные графы десятков видов моделей, и при этом не сходить с ума.


        1. Fen1kz
          16.04.2018 14:22
          +1

          Забавно, я в своем профессиональном развитии как раз перешел на redux чтобы не держать в памяти модели-сторы с перекрестными связями и не сходить от этого с ума. Ибо до этого было что-то похожее, но постарее (JSData) — так вот там как раз огреб весь кошмар от «это computable проперти зависит от этой модели и от этой, а чтобы их загрузить надо знать ещё эту модель. и когда мы обновляем что-то, у нас идет пересчет кучи моделей.»


          1. VolCh
            16.04.2018 14:23
            +1

            Прелесть MobX, что не надо знать. граф зависимостей под капотом вычисляется и перевычисляется автоматически.


            1. faiwer
              16.04.2018 14:36
              +1

              Вроде как нигде не нужно знать, и везде всё вычисляется автоматически. Вопросы возникают когда "что-то пошло не так", стектрейс прямиком из ада, в проекте цепочки синхронно-асинхронных-отложенных зависимых computed-значений (этакая матрёшка в много разнородных слоёв). Последовательность вычислений начинает сильно зависеть от конкретных кликов мыши и др. нюансов. И в итоге клубок собирается по-разному. И даже воспроизведя проблему ты не знаешь, как это повторить заново. У меня был такой опыт (KnockoutJS), неоднократно.


              1. TheShock
                16.04.2018 14:46
                +1

                Простите, а вы пользовались МобХ? Я просто пользовался довольно много, написал уже не один проект (и довольно сложные) и никогда не стыкался с такими проблемами


                1. faiwer
                  16.04.2018 14:56

                  Нет, только KnockoutJS. MobX и Vue у меня первые в списке на пробу, когда подвернётся возможность. Очень уж много про них хорошего говорят.


              1. Fen1kz
                16.04.2018 16:24
                -1

                Вот я про это же. Причем в обычных веб приложениях этого можно избежать если вся система достаточно простая, но если прям «сложные графы десятков видов моделей»…


    1. faiwer
      16.04.2018 09:24

      Я пытаясь уйти от копипасты пришёл к такой схеме:


      const A = actionFactory('PREFIX');
      
      export const aExpandTreeItem = A.create('EXPAND', 'id');
      export const aSomeAnotherAction = A.create('SOME', 'arg1, arg2, arg3');
      // === (arg1, arg2, arg3) => ({ type: `PREFIX_SOME`, arg1, arg2, arg3 });
      
      const map = 
      {
        [aExpandTreeItem]: (state, { id }) => { /* ... */ },
        [aSomeAnotherAction]: (state, { arg1, arg2, arg3 }) => { /* ... */ },
      };

      Но дубляжа осталось всё равно много, просто он стал компактнее. Дублируются поля в action-ах дважды, дублируются имена action-ов четырежды (export, import, A.create, map). Меньше помощи от IDE за счёт строк (правда мне ST3 итак не помогает)


      Потенциально можно убрать actionCreator-ы совсем, генерируя их из map-ы. Но это терпит крах когда нам нужны actionCreator-ы, которые являются композицией над другими и когда нужны async-actionCreator-ы. Да и вообще не всегда хочется держать список доступных action-ов там же, где и их reducer.


      Сложилось впечатление, что пока мы держим action-ы и reducer-ы отдельно, и хотим какой-то надёжности на уровне разруливания зависимостей в import-export — копипасты будет много.


  1. Druu
    16.04.2018 03:02

    [не туда]