Перейти к основному содержимому

Используйте реакции с осторожностью

В документации Mobx указано, что реакции нужно использовать с осторожностью. Разберёмся почему.

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

В главе про реакции мы уже разобрали, что под реакциями подразумеваются функции reaction и autorun. Их можно использовать для вызова побочных эффектов в ответ на изменения observable и computed. Рассмотрим следующий пример:

1. Computed вместо реакции

class UserStore {
age = 15
isAllowed = false

constructor() {
makeAutoObservable(this)
autorun(() => {
this.isAllowed = this.age >= 18
})
}

setAge(age: number) {
this.age = age
if (this.isAllowed) {
console.log('Доступ есть')
} else {
console.log('Доступа нет')
}
}
}

const userStore = new UserStore()
userStore.setAge(20)

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

Проблема решается с помощью computed:

class UserStore {
age = 15

constructor() {
makeAutoObservable(this)
}

setAge(age: number) {
this.age = age
if (this.isAllowed) {
console.log('Доступ есть')
} else {
console.log('Доступа нет')
}
}

get isAllowed() {
return this.age >= 18
}
}

const userStore = new UserStore()
userStore.setAge(20) // Теперь вывод 'Доступ есть'

Часто реакции можно просто заменить на computed и получить более надёжный код, без ручных подписок.

2. Отсутствие строгого порядка реакций

Мы можем пойти дальше и сделать цепочки computed, которые будут зависеть друг от друга. Mobx построит граф зависимостей и будет вычислять значения в строгом порядке:

class ShopStore {
shop = {
taxPercent: 8,
items: [
{ name: "apple", value: 1.2 },
{ name: "orange", value: 0.95 }
]
}

// Сумма без налогов
get subtotal() {
return this.shop.items.reduce((acc, item) => acc + item.value, 0)
}

// Налоги
get tax() {
return this.subtotal * (this.shop.taxPercent / 100)
}

// Общая сумма
get total() {
return this.subtotal + this.tax
}
}

Мы можем использовать реакции для такого кода, но Mobx не гарантирует детерминированный порядок выполнения этих реакций:

class BrokenStore {
shop = {
taxPercent: 8,
items: [
{ name: "apple", value: 1.2 },
{ name: "orange", value: 0.95 }
]
}

tax = 0
subtotal = 0
total = 0

constructor() {
makeAutoObservable(this)
autorun(() => {
const subtotal = this.shop.items.reduce(
(acc, item) => acc + item.value,
0
);
runInAction(() => {
this.subtotal = subtotal;
});
})
autorun(() => {
const tax = this.subtotal * (this.shop.taxPercent / 100);
runInAction(() => {
this.tax = tax;
})
})
autorun(() => {
const total = this.tax + this.subtotal;
runInAction(() => {
this.total = total;
})
})
}
}

Обратите внимание, что все модификации изменяемых значений обёрнуты в экшен. При этом более короткую запись autorun(action(() => ...)) использовать нельзя, иначе будет ошибка [MobX] Autorun does not accept actions since actions are untrackable. Не самый очевидный, но намёк от библиотеки, что мы что-то сделали неправильно. Код уже не такой чистый, к тому же реакции могут запускаться в случайном порядке и результат непредсказуем.

3. Группировка изменения и побочного эффекта

Представим что вы видите в коде articleStore.updateText(newValue) и хотите узнать что происходит при изменении текста статьи. Кликаем на updateText и видим такой стор:

class ArticleStore {
text = ''

constructor() {
makeAutoObservable(this)
}

updateText(text: string) {
this.text = text
}
}

Тут только обновляется текст. Но во вкладке Network в девтулзах почему-то отправляется HTTP запрос на каждое изменение текста. Почему? Потому что кто-то в неизвестном месте создал реакцию, которая сохраняет статью на каждое изменение текста:

reaction(
() => articleStore.text,
value => apiSaveArticle(value)
)

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

Исправить это можно, объединив изменение состояния и побочный эффект:

class ArticleStore {
text = ''

constructor() {
makeAutoObservable(this)
}

updateText(text: string) {
this.text = text
apiSaveArticle(this.text)
// Без debounce и проверки на изменение для простоты примера
}
}

Преимущества такого подхода:

  • Это обычный последовательный код, он не вводит новых концепций в отличие от реакций. Сразу видно какое действие к каким эффектам приводит, как движется поток данных. Поэтому его проще отслеживать, понимать и сложнее внести ошибку.
  • Не требуется очищать реакции. Функции autorun и reaction возвращают функцию для удаления подписки во избежания утечек памяти: https://mobx.js.org/reactions.html#always-dispose-of-reactions

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

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

Если вы предпочитаете событийно-ориентированное управление состоянием, то для этого есть более подходящие инструменты, такие как Effector, Reatom, RxJS. Код на Mobx будет проще, так как ориентирован на состояние, а не на события.

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

class ArticleStore {
text = ''

constructor() {
makeAutoObservable(this)
}

updateText(text: string) {
this.text = text
emitter.emit('articleChanged', this.text)
}
}

// В другом модуле
emitter.on('articleChanged', apiSaveArticle)

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

Когда применять реакции

Есть ситуации при которых реакции дают удобство и не нарушают правил, описанных выше. Например:

  • Обновление React компонентов в ответ на изменение observable значений. Компонент observer как раз внутри использует реакции. Если вы пишете Mobx-обёртку для новой UI-библиотеки, то вам тоже нужны реакции.
  • Автоматическая сериализация observable в localStorage, IndexedDB и прочие сторонние хранилища

Вывод

После разбора примеров выше можем сформулировать простые правила использования реакций:

  • Реакции не должны модифицировать другие изменяемые значения. Реакции не должны зависеть друг от друга, так как Mobx не гарантирует строгий порядок их выполнения.
  • Вместо использования реакций можно использовать computed или группировку изменений в одном экшене.