Управление состоянием - MobX

Привет, на связи Antihype JS. Это первая публикация из цикла статей по разбору стейт менеджеров. Гость нашего сегодняшнего выпуска - проверенная временем библиотека MobX.

MobX - это framework agnostic, мультистор стейт менеджер. Библиотеку можно использовать вместе с React, Vue и даже Angular. MobX использует паттерн Observer. Классическая реализация шаблона наблюдатель предусматривает ручные подписки на изменения, например вызов .subscribe() в RxJS.

Библиотека использует автоматические подписки на изменения наблюдаемых свойств внутри реактивных контекстов. К тому же, MobX не требует от вас работы с иммутабельными данными. Добавить элемент в массив - просто используем метод .push, и больше никаких { …state, object: { …state.object, name } }.

Примитивы MobX

В любом UI приложении есть следующие составляющие:

Управление состоянием - MobX
  • Event - любые события из внешнего мира, как правило под этим понимают любые пользовательские действия или события от внешних систем.
  • Actions - методы/функции, которые обновляют состояние приложения.
  • Observable state - всё состояние приложения: текущий роут, состояние сторов и т.д.
  • Computed values - чистые вычисляемые значения из одного или многих observables.
  • Side effects - логирование, запросы, рендер и прочее. В контексте MobX сайд эффекты обычно называют реакциями.

Чтобы начать использовать MobX достаточно создать простой класс и сделать наш стор реактивным с помощью вызова функции makeObservable в конструкторе:

import { makeObservable, observable, action, computed } from 'mobx' class CounterStore { count: number = 0 constructor() { makeObservable(this, { count: observable, inc: action, dec: action, double: computed, }) } get double() { return this.count * 2 } inc = () => { this.count++ } dec = () => { this.count-- } }

Поля объекта оборачиваются с помощью функций observable, action, computed. Для удобства на практике чаще используют функцию makeAutoObservable:

import { makeAutoObservable } from 'mobx' class CounterStore { count: number = 0 constructor() { makeAutoObservable(this) } get double() { return this.count * 2 } inc = () => { this.count++ } dec = () => { this.count-- } }

в таком случае:

  • поля класса будут помечаться как observable значения
  • методы будут помечаться как action
  • геттеры будут помечаться как computed значения

Реакции

MobX реагирует на чтение любого наблюдаемого свойства внутри реактивного контекста.

Такой контекст создают:

  • computed геттеры
  • React компонент, обернутый в функцию observer
  • функции, переданные в autorun, reaction и when

Если вы обращаетесь к observable значениям вне такого контекста, то отслеживание изменений перестает работать.

autorun

import { autorun } from 'mobx' const counterStore = new CounterStore() autorun(() => { console.log('count:', counterStore.count, 'double:', counterStore.double) }) counterStore.inc() // В консоли: // count: 0 double: 0 // count: 1 double: 2

Autorun принимает функцию, которая запускается каждый раз, когда изменяется любое отслеживаемое внутри неё observable/computed значение. Также переданная функция запускается сразу же при выполнении autorun, поэтому в консоли мы видим начальные значения счетчика.

reaction

import { reaction } from 'mobx' const counterStore = new CounterStore() reaction( () => counterStore.count, (count, prevCount) => { console.log('count:', count, 'prevCount:', prevCount) } ) counterStore.inc() // В консоли: // count: 1 prevCount: 0

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

При изменении поля count будет запущен наш сайд эффект с актуальным значением счетчика. Также в эффекте можно использовать второй аргумент, который является предыдущим значением счетчика.

Важно отметить, что эффект не запускается сразу же после создания реакции. Это поведение можно изменить с помощью настроек реакции:

import { reaction } from 'mobx' const counterStore = new CounterStore() reaction( () => counterStore.count, (count, prevCount) => { console.log('count:', count, 'prevCount:', prevCount) }, { fireImmediately: true } ) counterStore.inc() // В консоли: // count: 0 prevCount: undefined // count: 1 prevCount: 0

observer

import { observer } from 'mobx-react-lite' export const App = observer(() => { return ( <div> <p>{counterStore.count}</p> <button onClick={counterStore.inc}>+</button> </div> ) })

Observer автоматически ререндерит компонент при изменении любых наблюдаемых свойств стора. В нашем примере компонент подпишется на изменения count и будет ререндериться только при его изменении.

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

Observer дает гарантию, что ререндеринг компонентов не будет произведен, если нет изменений отслеживаемых значений. Это поведение очень похоже на работу React.memo. Такой подход делает приложения на MobX достаточно оптимизированными по умолчанию.

Чтобы не забыть обернуть компонент в observer, можно подключить официальный ESLint плагин с правилом mobx/missing-observer.

Асинхронные операции

Для работы с асинхронным кодом в MobX не нужно никаких специальных манипуляций и танцев. Ререндеры компонентов или другие пользовательские реакции не зависят от этого. Однаков для корректной работы реакций, необходимо помнить про оборачивание кода в action. По умолчанию внутри любого action работает батчинг изменений, например при изменении значения счетчика 3 раза подряд:

class CounterStore { count: number = 0 constructor() { makeAutoObservable(this) } inc = () => { this.count++ this.count++ this.count++ } } const counterStore = new CounterStore() reaction( () => counterStore.count, (count) => console.log('count:', count) ) counterStore.inc() // В консоли: // count: 3

наша реакция сработает только 1 раз с финальным значением 3.

Но если мы добавим, например, запрос к серверу:

class CounterStore { count: number = 0 constructor() { makeAutoObservable(this) } inc = async () => { await request() this.count++ this.count++ this.count++ } } const counterStore = new CounterStore() reaction( () => counterStore.count, (count) => console.log('count:', count) ) counterStore.inc() // В консоли: // count: 1 // count: 2 // count: 3

то автоматический батчинг перестает работать. Любой код после await уже работает вне контекста action. Для решения этой проблемы можно использовать утилиту runInAction:

inc = async () => { await request() runInAction(() => { this.count++ this.count++ this.count++ }) } // В консоли // count: 3

Или использовать функцию fromPromise из пакета mobx-utils.

Заключение

Мы сделали обзор на основные концепции стейт менеджера MobX. Использовать его в проде или нет - как обычно, решать только вам. Если вы устали от Redux, то советуем взглянуть на проверенный временем MobX. А в следующей статье мы разберем работу стейт менеджера Effector.

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

1010
8 комментариев

Мне кажется это лучше на хабре

1
Ответить

Хабр мёртв

2
Ответить

Братан, хорош, давай, давай, вперёд! Контент в кайф, можно ещё? Вообще красавчик! Можно вот этого вот почаще?

1
Ответить

Комментарий недоступен

Ответить