Redux vs Effector: стоит ли переходить?

Олег Кусов07.12.2021

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

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

Я уже поработал с эффектором на своём проде и могу составить определенное мнение. Но начнем мы с Toolkit. Он супер простой. За вас даже сделали темплейт, по которому можно быстро начать разрабатывать приложение. Тулкит неплохо обрабатывает состояния запросов с помощью createAsyncThunk. Эффектор также из коробки хэндлит запросы. Однако в остальном библиотеки очень различаются.

Флоу тулкита очень прост. Есть общий стейт, который убирает проблему цикличных зависимостей. Есть extraReducers, которые обрабатывают запросы на бэкенд как вам угодно. Есть обычные редьюсеры, которые обрабатывают различную логику приложения, не связанную с получением данных. И это весь Toolkit - супер просто и прямолинейно.

Поэтому, на мой взгляд, Toolkit идеален для обычных разработчиков, которые вообще не хотят париться и просто хотят релизить быстро фичи. Основная логика будет скрыта в редьюсерах, а что-то другое можно хранить в хуках, поэтому компоненты получаются довольно чистыми и UI неплохо отделяется от бизнес логики. Да, возможно на скорую руку разработчики будут писать некоторый повторяющийся код, но главное, что Toolkit это позволяет делать.

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

С Redux Toolkit, кажется, всё понятно. Он прост, довольно удобен и позволяет быстро пилить фичи.

Эффектор

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

Кроме юнитов есть различные функции, которые позволяют обрабатывать логику декларативно: sample, combine, map, guard, attach, merge, split, forward, reset, createApi и так далее. Эффектор похож на конструктор Лего и Blueprints в Unreal Engine, когда из маленьких кубиков можно собрать сложную логику изменения стейта с различными связями этих кубиков друг с другом.

Самое главное - стор. Создаем его так:

const $someStore = createStore(<начальное значение>);

Как поменять значение в сторе? Есть много вариантов: через событие или через декларативные конструкции, перечисленные выше.

sample({
  clock: someEvent,
  source: $anotherStore,
  fn: (source, clock) => /* некоторая логика здесь по аналогии с редьюсером в редакс */ 
  target: $someStore,
})

Здесь clock - это иницилизирующее событие, target - юнит, куда данные будут улетать из fn, а с помощью source можно брать данные из сторов.

Некоторые юниты можно вкладывать друг в друга. Например, если мы хотим заносить значение в target только по какому-нибудь условию, можно вложить в clock guard:

sample({
  clock: guard({
     source: someEvent,
     filter: (payload) => /* какая-нибудь фильтрующая логика */
   }),
  source: $anotherStore,
  fn: (source, clock) => /* некоторая логика здесь по аналогии с редьюсером в редакс */ 
  target:  $someStore,
})

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

sample({
  clock: guard({
     clock: someEvent
     source: $anotherStore,
     filter: (source, clock) => /* какая-нибудь фильтрующая логика */
   }),
  fn: (source, clock) => /* некоторая логика здесь по аналогии с редьюсером в редакс */ 
  target:  $someStore,
}))

А вот так выглядит Split, он позволяет разделить поток на два пути:

split({
  source: messageReceived,
  match: {
    text: msg => msg.type === 'text',
    audio: msg => msg.type === 'audio',
  },
  cases: {
    text: showTextPopup,
    audio: playAudio,
    __: reportUnknownMessageTypeFx,
  },
})

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

Toolkit задает свои правила, но в гораздо менее жесткой форме, чем Эффектор, от чего писать код с ним оказывается проще. Эффектор - отличный и интересный стейт менеджер, но сложный.

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

В Redux стейт обрабатывается через useEffect и в редьюсерах. Эффектор в этом вопросе всё берет на себя. С помощью различных декларативных конструкций он позволяет реализовать любую логику и полностью изолировать её от UI, отказавшись даже от хуков и useEffect.

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

Что не понравилось? С эффектором сложно хендлить взаимосвязи стейта. Мне пришлось несколько раз переписывать отдельные участки кода и сидеть по несколько часов, обдумывая как правильно сформировать связи между юнитами. Понятно, что с опытом это всё пойдет быстрее, но всё же.

Например, вот один из кейсов:

Вызывается openPopup, его вызов тригерит вызов эффекта getUserByGenderFx и одновременно заносится значение в стор $gender. getUserByGenderFx получает данные из $gender, а так как они вызываются одновременно, на момент вызова getUserByGenderFx стейт в $gender бывает еще устаревшим. Какой выход?

Можно поменять поток событий и сделать так, чтобы openPopup тригерил изменение $gender, который, в свою очередь, тригерил бы вызов getUserByGenderFx со свежим значением из $gender. Но есть минус. Вы уже могли заметить, что $gender меняется еще и при событии changeGenderEvent(). Поэтому вызов этого события приведет к вызову getUserByGenderFx из-за чего логика приложения опять ломается. Есть ли решение?

Мы можем создать копию стейта $gender - $gender_copy, которая будет наравне с $gender тригерится при вызове openPopup, но при этом уже не будет зависеть от changeGenderEvent(). Скорее всего, было более оптимальное решение, но я остановился на этом. Я понимаю, что с редаксом подобное могло бы выглядеть аналогично, но при этом с редаксом есть понимание того, как можно легко обойти проблему даже с помощью дублирования кода, получив работающий результат. В случае с эффектором дублировать что-либо не хочется - он к этому не располагает. К тому же, есть ощущение, что реализовав данный конструктор на редаксе, я бы прибегнул к иным логическим связям, потому что видел бы картину чуть иначе, и, возможно, таких проблем бы не случилось. Можно, конечно, попытаться реализовать всё с нуля на редаксе, но где же взять время.

Выводы

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

P.S. Возможно, я поменяю своё мнение спустя время, когда пойму, что оказался не прав, но пока так :)