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

Асинхронные действия

Асинхронные действия не нуждаются в какой-либо специальной обработке в MobX, так как все реакции обрабатываются автоматически независимо от места и момента их возникновения. Однако каждый шаг (tick), который обновляет наблюдаемые поля внутри асинхронного процесса, должен быть помечен как action. Этого можно достичь несколькими способами.

Слушатель всех событий

Перейдите в индексный файл /src/index.tsx и добавьте следующий код:

import { spy } from 'mobx'

spy((ev) => {
console.log(ev)
})

Мы импортировали из MobX функцию spy и передали в нее слушатель с console.log. Spy регистрирует глобальный слушатель, который логирует все события, происходящие внутри MobX.

Если вы откроете консоль, то увидите множество внутренней информации о работе MobX. Чтобы отфильтровать лишнее, давайте оставим только те события, которые в имени содержат слово action:

spy((ev) => {
if (ev.type.includes('action')) {
console.log(ev)
}
})

Откроем консоль и кликнем на кнопку "+":

{type: "action", name: "inc", object: undefined, arguments: Array(1), spyReportStart: true} // 1

{name: "observer_c", type: "scheduled-reaction"} // 2

{name: "observer_c", type: "reaction", spyReportStart: true} // 3

Мы видим, что после клика вызвался (1) экшн с именем inc. Потом запустился (2) планировщик реакций (scheduled-reaction), а потом и сама реакция (3). Реакцией в данном случае является рендеринг нашего компонента. У него странное имя observer_c, потому что наш компонент представляет собой анонимную функцию. Давайте это исправим в файле /src/App.tsx:

import { observer } from 'mobx-react-lite'
import { counterStore } from './counter.store'

export const App = observer(() => {
const { count, inc, dec } = counterStore

return (
<div className='App'>
<h1>{count}</h1>

<button onClick={inc}>+</button>
<button onClick={dec}>-</button>
</div>
)
})

App.displayName = 'App'

Теперь видим, что реакция произошла в наблюдателе (observer) с именем App, т.е. в нашем компоненте.

Объединение обновлений

Давайте перейдем к файлу /src/counter.store.ts и в методе inc вызываем инкремент count трижды:

import { makeAutoObservable } from 'mobx'

class Store {
count = 0

constructor() {
makeAutoObservable(this)
}

inc = () => {
this.count++
this.count++
this.count++
}

dec = () => {
this.count--
}
}

export const counterStore = new Store()

Если вы откроете консоль и нажмете на кнопку "+", то увидите что реакция запустилась всего лишь один раз, несмотря на то, что мы трижды мутировали count. Почему? Потому что промежуточные состояния не видны наблюдателям. MobX просто копит эти изменения и откладывает уведомление подписчиков до завершения блока транзакции.

Но эта логика ломается если добавить асинхронные действия в наш экшн.

import { makeAutoObservable } from 'mobx'

const delay = (ms: number) => new Promise((_) => setTimeout(_, ms))

class Store {
count = 0

constructor() {
makeAutoObservable(this)
}

inc = async () => {
await delay(10)
this.count++
this.count++
this.count++
}

dec = () => {
this.count--
}
}

export const counterStore = new Store()

Функция delay принимает число и возвращает Promise, выполнение которого откладывается через setTimeout. Вместо delay мы могли бы написать запрос к API с помощью fetch или любую другую асинхронную функцию, но для иллюстрации достаточно простой задержки.

Теперь если открыть консоль и кликнуть на кнопку "+", то мы увидим что запустился экшн ({ type: "action", name: "inc" }), а потом происходит три реакции. По одному на каждый инкремент.

[MobX] Since strict-mode is enabled, changing (observed) observable values without using an action is not allowed. Tried to modify: Store@1.count

{name: "observerApp", type: "scheduled-reaction"}

{name: "observerApp", type: "reaction", spyReportStart: true}

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

runInAction

Чтобы исправить это поведение, можно использовать функцию runInAction из пакета mobx.

inc = async () => {
await delay(10)
runInAction(() => {
this.count++
this.count++
this.count++
})
}

Если вернемся к консоли, то увидим что планировщик реакции и сама реакция запустятся один раз.

Проблема решена, но каждый раз оборачивать код в runInAction может быть утомительным. Есть несколько альтернативных решений этой проблемы.

setTimeout планировщика реакций

MobX позволяет конфигурировать поведение планировщика реакций. По умолчанию reactionScheduler просто запускает реакцию f без какого-либо другого поведения:

import { configure } from 'mobx'

configure({
reactionScheduler: (f) => {
f()
},
})

Настройка reactionScheduler может быть полезна для логирования или отладки. Кроме того, с помощью setTimeout мы можем отложить выполнение реакций:

configure({
enforceActions: 'never',
reactionScheduler: (f) => {
setTimeout(() => {
f()
}, 0)
},
})

Эквивалентный код:

configure({
enforceActions: 'never',
reactionScheduler: (f) => setTimeout(f),
})

Таким образом, планировщик будет откладывать реакции до тех пор, пока все ваши синхронные изменения не закончатся. То есть мы таких образом накапливаем изменения, ждем их завершения и потом вызываем реакции на эти изменения. Подобный трюк позволяет нам отказаться от runInAction и от использования flow-генераторов вместо async / await.

Вместе с тем нужно понимать, что если какой-то сторонний код аналогично откладывает задачи с помощью setTimeout, вместо того, чтобы выполнять их сразу, то порядок обработки становится непредсказуемым и могут возникать трудновоспроизводимые ошибки. В частности, из опыта авторов, отложенный reactionScheduler нарушает работу библиотеки pusher-js и создает проблемы в среде React Native.

Официальная позиция Mobx - не использовать подобные трюки, а вместо этого использовать runInAction или генераторы.

Генераторы

Другой способ избежать проблемы - использовать генераторы:

import { makeAutoObservable } from 'mobx'

const delay = (ms: number) => new Promise((_) => setTimeout(_, ms))

class Store {
count = 0

constructor() {
makeAutoObservable(this)
}

*inc() {
yield delay(10)
this.count++
this.count++
this.count++
}

dec() {
this.count--
}
}

export const counterStore = new Store()

Этот подход является наиболее чистым, так как не влияет ни на вложенность кода, ни на планировщик реакций. Однако у него есть ряд сложностей с типизацией. Как решение можно использовать это вспомогательную функцию для Mobx: https://github.com/mobxjs/mobx/discussions/3195#discussioncomment-2379437

Можете проголосовать за эту функцию на гитхабе, чтобы её добавили в ядро Mobx.

Как использовать генераторы с Mobx будет описано в отдельной статье.