Ссылка на вторую часть статьи: «Пример на MobX».

Эта часть статьи будет об осмыслении составляющих Redux. Так ли они необходимы, что является их аналогом. Также будет предложена более удобная альтернатива хука useReducer.

На самом деле, я не буду воспроизводить Redux. Вместо этого, в следующей части будет описано, как спроектировать более удобную архитектуру с использованием MobX. Следующая часть в основном для тех, у кого проекты на MobX получались запутанными, с неконтролируемыми изменениями.

В этой части статьи я хочу показать, что:

  • редьюсеры - это аналоги обычных чистых функции для получения нового состояния.

  • селекторы - это аналоги обычных функций с мемоизацией, которые возвращают данные.

  • dispatch + action + action creators - это аналог обычных вызовов функций, и разбиение на dispatch, action, action creators является зачастую ненужным и используются не к месту.

В статье не будет рассматриваться Redux Toolkit и прочие библиотеки для уменьшения бойлерплейта. Только то, в каком виде Redux использовался изначально. Отмечу, что похожая структура кода сторов, к которой пришли разработчики библиотеки Redux, существовала до появления Redux Toolkit в более user-friendly виде в других менеджерах состояний, вроде MobX, Vuex (я буду иногда его упоминать, т.к. он похож на MobX, и я немного знаком с ним).

Содержание первой части

Одно хранилище (стор) vs множество хранилищ

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

Проекты c Vuex и MobX прекрасно работают с множеством сторов. Даже более, наличие сторов в проекте необязательно. Но отделение данных и логики для работы с ними от остальной части программы, а также отделение данных от логики дают дополнительные возможности и удобства при разработке.

"Единственный источник правды" в определенном понимании действительно хорошая идея, упрощающая структуру приложения. Проще понять систему, когда ее структура везде одинаковая и все не локальные данные (данные с сервера, данные с localStorage/sessionStorage, общие данные не связанных напрямую компонентов) компонентов проходят через стор, хоть это и требует написание дополнительного кода. Но в отличие от Redux подхода, я бы считал единственным источником правды не один единственный стор на все приложение, а сам слой сторов.

Reducer vs чистая функция для мутации состояния. Нарушение SOLID

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

Из минусов - изначальная реализация сделана на ugly switch и имеет сложность O(n), а также количество ответственностей равное количеству actions в редьюсере. На практике сложность O(n) вряд ли заметно повлияет на производительность, если у вас не графическое приложения с перерисовкой по 60 раз в секунду. Другое дело – лишний и усложненный код и ухудшение масштабируемости. Даже в редьюсерах можно заменить switсh на словарь с парами ключ-значение [actionNameKey][function] и код уже станет лучше.

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

Редьюсеры нарушают 3 принципа SOLID и один из принципов GRASP

Насколько я вижу, редьюсеры нарушают некоторые принципы проектирования. Некоторые из принципов проектирования довольно похожи, а соблюдение/нарушение одного ведет к соблюдению/нарушению второго. Нарушать их можно и зачастую даже нужно. Чрезмерное следование принципам проектирования ведет к ненужному усложнению кода. Об этом можно почитать по ссылкам "Когда не нужно использовать SOLID" и "О принципах проектирования". Особо интересный комментарий: "вопрос не в том, когда не нужно использовать SOLID, а в том, насколько использовать каждый из его принципов в своем, конкретном случае". И это вполне применимо и к другим принципам. Если какой-то принцип противоречит другому, то стоит выбирать в какой степени какой из принципов уместней будет нарушить/применить. В случае редьюсеров, хоть они и нарушают несколько принципов, к серьезным проблемам это не приводит, если нарушать принципы в меру. Но можно писать гораздо лучше код обновления состояния, не нарушая эти принципы. Даже команда Redux со временем по большей части исправила нарушение этих принципов в Redux Toolkit.

Далее я буду употреблять термин "программные сущности". Имеются ввиду классы, модули, функции и т. п.

Нарушение single-responsibility principle (SRP)

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

Ошибочно может показаться, что у редьюсера только одна ответственность - вычислить новое состояние. На самом деле их много. Одна функция редьюсер отвечает за обработку разных actions. То есть количество причин для изменения редьюсера равно количеству actions. Когда редьюсер разрастается, он начинает отвечать за слишком многое. Конечно, тут приходит на помощь combineReducers.

Нарушение принципа открытости/закрытости

Принцип открытости/закрытости означает, что программные сущности должны быть:

  • открыты для расширения: означает, что поведение сущности может быть расширено путём создания новых типов сущностей.

  • закрыты для изменения: в результате расширения поведения сущности, не должны вноситься изменения в код, который эту сущность использует.

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

Если в программу добавится новый action для работы с одним уже существующим участком стора, то потребуется менять и код внутри редьюсера. А это уже нарушение принципа. Если использовать словари (а объекты в JS и есть словари), то проблема исчезает. В отличие от функции, словарь открыт для расширения и позволяет добавлять новые пары action-функция.

С другой стороны, редьюсеры слабо связаны с остальной частью программы и нарушение именно этого принципа в редьюсерах вряд ли приведет к существенным проблемам в обычных проектах. Если же делать, например, фреймворк поверх Redux с какими-то определенными actions и редьюсерами, то у программистов, использующих этот фреймворк, уже может возникнуть проблема с расширением его редьюсеров. Также стоит отметить, что привязка редьюсеров к конкретным типам actions затрудняет повторное использование редьюсеров.

Нарушение принципа подстановки Барбары Лисков

LSP принцип - функции, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа, не зная об этом.

https://medium.com/webbdev/solid-4ffc018077da - в этой статье описан случай, при котором нарушается этот принцип: "Если оказывается, что в коде проверяется тип класса, значит принцип подстановки нарушается."

К типам событий, базовым типам и константным значением это тоже относится. Насколько я понимаю, любая проверка на конкретное значение входящего параметра функции нарушает принцип LSP, если вариантов значений в будущем может стать больше. Изначально данный принцип ограничивался наследованием, а сейчас он расширен. Об этом упоминается в книге "Чистая архитектура ..." Роберта Мартина в главе, посвященной этому принципу.

Вернемся к редьюсерам. Думаю, понятно, что редьюсеры, сделанные с использованием switch, нарушают данный принцип, так как каждый тип action они обрабатывают по-разному.

Последствия нарушения этого принципа аналогичны последствиям нарушения предыдущего принципа.

Нарушение принципа высокой связности (High Cohesion) из GRASP

Связность (не путать со связанностью/зацеплением) - то, насколько данные внутри модуля связанны друг с другом. "То, насколько хорошо все методы класса или все фрагменты метода соответствуют главной цели." Хорошо, когда связность высокая и плохо, когда низкая.

В редьюсере, использующем switch, несколько действий объединены в одну функцию. Они связаны состоянием, которое изменяют. Иногда есть связность по передаваемым в редьюсер данным. Но отсутствует связность по action.type. К тому же, сами действия в разных case не зависимы друг от друга и выполняют разные задачи. Для объекта/класса естественно хранить в себе несколько функций их зачастую можно переопределить/заменить. Но когда функция содержит в себе несколько незаменяемых функций для выполнения разных задач - это уже низкая связность, что плохо.

Данный принцип тесно пересекается с SRP принципом из SOLID. Последствием нарушения данного принципа в редьюсерах является снижение читабельности кода и усложнение их повторного использования. Редьюсер, обрабатывающий несколько actions, мало где можно использовать повторно. Функцию же, которая обрабатывает только один action, гораздо чаще можно использовать повторно.

Заключение по редьюсерам

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

Мутации с помощью чистых функции. Правильно, что у функции для изменения стора должно быть только одно назначение – изменить его состояние. Не правильно, что поведение функции зависит от входящего параметра - action, типов которого может быть довольно много. Является функция чистой или нет - это не главное. Главное - делать функции в сторах как можно проще, читабельней, не реализовывать в них сторонний функционал. Вместо редьюсеров с таким кодом:

case 'todos/todoAdded': {
  return {
    ...state,
    todos: [
      ...state.todos,
      action.paylod.newTodo
    ]
  }
}

можно было бы писать примерно такие функции:

function todoAdded(state, newTodo) {
  return {
    ...state,
    todos: [
      ...state.todos,
      newTodo
    ]
  }
}

Функция-редьюсер заменена обычной чистой функцией, возвращающей новое состояние. Вместо поля type в action, как в Redux, здесь используется имя функции. И у функции только одна ответственность - изменить определенный участок состояния. Даже если потребуется вызывать функции изменения стора с помощью событий, все равно есть возможность вызвать функцию, передав имя события/функции через строковую переменную: todoStore['todoAdded'].

Селектор vs функция с мемоизацией, возвращающая данные

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

Аналогом селекторов в MobX являются вычисляемые значения (computed values). Так же, если надо просто сократить запись, можно использовать обычные JS геттеры. Можно сделать геттеры вычисляемыми значениями. Стоит упомянуть, что и в Vuex есть аналог селекторов - геттеры.

Согласно Redux, селекторы можно использовать как в компонентах, так и в middleware. Я использую аналогичную логику. Только геттеры и вычисляемые значения в случае MobX являются частью сторов.

Бизнес-логика в сторах MobX vs бизнес-логика в middleware's

В Redux сайд-эффекты вынесены из стора и это, на мой взгляд, более правильно в больших проектах.

В Vuex и MobX очень распространен подход, когда в action пишется вызов API и прочая бизнес-логика. В Vuex сторах сторонняя бизнес-логика вообще является частью сторов. Я считаю, что это превращает стор в аналог контроллера с пассивной моделью. То есть в контроллере пишется бизнес-логика, а модель ответственна за получения данных (из базы или с сервера). В MVC это считается плохим подходом. О пассивной и активной модели MVC можно прочитать в wikipedia - MVC, наиболее частые ошибки. С другой стороны, сторы Vuex и MobX - это вариация MVVM, а не MVC.

На мой взгляд, такой подход подходит, когда в сторах не слишком много кода. Если один стор взаимодействует только с одним небольшим компонентом, то проблем не возникнет. Если один стор взаимодействует с несколькими компонентами на одной страницей, то могут возникнуть сложности, как и в AngularJS 1.x. Если один стор взаимодействует с несколькими страницами, то еще выше вероятность получить проблемы.

С какого-то момента это с большой вероятностью затруднит рефакторинг и повторное использование кода. Если у функции 2 ответственности (например, api запрос и изменение состояния), то ее сложнее использовать повторно. Функцию можно разбить в пределах стора – тогда у самой функции проблема исчезнет, но она не исчезнет у класса стора. Если же у функции или класса будет только одна ответственность, то будет меньше причин для их изменений и их чаще и проще можно будет использовать повторно.

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

Action creators, actions, dispatch VS прямые вызовы функций

Действие в Redux - это просто объект с именем события. Фактически можно рассматривать действие как событие.

События нужны, чтобы оповестить определенные программные сущности, что в системе произошло какое-то действие. Диспетчеризация событий в Redux является вариацией паттерна pub/sub (издатель-подписчик).

Pub/sub нужен для передачи событий от издателя к подписчикам через посредника (канал событий) так, чтобы издатель и подписчик не знали друг о друге.

Я полагаю, что события можно использовать для случаев, если:

  • нужно избавиться от большой связанности. Например, когда один (либо несколько) объект должен вызывать схожий метод у множества других объектов.

  • программная сущность должна уметь вызывать методы других объектов или оповещать, что выполнилось такое-то действие, но сама она закрыта для изменений. То есть она может вызывать только методы по заранее заданным именам, либо отправлять заранее определенные уведомления. Пример такой ситуации - вызовы методов жизненного цикла в React компонентах. Свой метод жизненного цикла в компонент не добавить, т.к. он не будет вызываться внутренним механизмом react-а.

Где в Redux используются action-ы? В трех местах:

  1. в компоненте, чтобы вызвать middleware;

  2. в middleware, чтобы обновить стор;

  3. в редьюсере, чтобы знать, как именно нужно обновить стор.

Что здесь не так? Все эти функции в типичном фронтенд приложении открыты для изменений. Их создает команда проекта, а не какая-то другая команда, у которой нет доступа к ним. К тому же actions не скрываются наследованием или еще каким-нибудь механизмом. Так что в типичных случаях для всех 3-х мест использования actions причиной может являться лишь избавление от чрезмерной связанности объектов. Рассмотрим 3 места использования actions для первого случая (избавление от большой связанности).

1. Подписка middleware-ом на событие компонента (отправителя событие). Посмотрим, зачем здесь нужна подписка?

Action, используемый для вызова middleware, не используется в сторе. То есть со стором он не связан.

Смотрим далее. Часто ли один компонент вызывает одним dispatch много middleware-ов? По-моему, практически никогда. Да и в таком случае сложнее отследить логику работы, т.к. не явно будут вызваны несколько функций. Более понятно будет, если объединить вызов нескольких middleware-ов в новом middleware и вызывать только его.

По-моему, подписку здесь можно безболезненно заменить обычным вызовом функции.

2. Middleware обновляет стор. Аналогично. Часто ли нужно обработать один action разными редьюсерами? Довольно редко такое встречается на практике и лучше заменить неявный вызов явным. Думаю, что подписку и здесь можно заменить на обычный вызов функции.

3. actions в редьюсере. Редьюсер принимает много actions и по имени action определяет, какой код надо выполнить. Это случай я уже рассматривал в главе о редьюсерах.

Дополнение - а нужен ли useReducer?

Как я говорил ранее, редьюсер - это просто группа разных действий для изменения состояния, объединенная в одну чистую функцию.

У useReducer аналогичная задача, но для состояния компонента. Я уже писал выше про недостатки редьюсеров и как упросить код. В компонентах обычно меньше actions, т.к. они локальные, поэтому и проблем меньше. Основные оставшиеся недостатки у useReducer - лишний код и снижение читабельности.

Функционал, аналогичный useReducer, можно сделать вручную через useState, но это долго и не удобно. Но можно не делать это каждый раз, а вынести отдельно, что я и сделал. Я написал хук useStateWithUpdaters, чтобы писать более читабельный и удобный код. Ниже пример его использования:

const updaters = {
  subtract: (prevState, value) => (
    { ...prevState, count: prevState.count - value }
  ),
  add: (prevState, value) => (
    { ...prevState, count: prevState.count + value }
  ),
};

const MyComponent = () => {
  const [{ count }, {add, subtract}] = 
        useStateWithUpdaters({ count: 0 }, updaters);
  return (
    <div>
      Count: {count}
      <button onClick={() => subtract(1)}>-</button>
      <button onClick={() => add(1)}>+</button>
    </div>
  );
};

Его реализацию вы можете найти в issue.
Есть TypeScript версия.

Также его можно немного улучшить - объединять новое состояние с предыдущим в реализации самого хука, чтобы не приходилось постоянно писать "…prevState".