Используйте реакции с осторожностью
В документации 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 или группировку изменений в одном экшене.