Skip to main content

Asynchronous Actions

Asynchronous actions don't need any special handling in MobX, as all reactions are handled automatically regardless of where and when they occur. However, each step (tick) that updates observable fields inside an asynchronous process must be marked as an action. This can be achieved in several ways.

Global Event Listener

Go to the index file /src/index.tsx and add the following code:

import { spy } from 'mobx'

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

We imported the spy function from MobX and passed it a listener with console.log. Spy registers a global listener that logs all events happening inside MobX.

If you open the console, you'll see a lot of internal information about MobX's operation. To filter out the noise, let's keep only those events that contain the word action in their name:

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

Open the console and click on the "+" button:

{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

We can see that after clicking, (1) an action named inc was called. Then (2) the reaction scheduler (scheduled-reaction) started, followed by the reaction itself (3). The reaction in this case is the rendering of our component. It has the strange name observer_c because our component is an anonymous function. Let's fix this in the /src/App.tsx file:

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'

Now we can see that the reaction occurred in the observer named App, i.e., in our component.

Batching Updates

Let's go to the /src/counter.store.ts file and call increment count three times in the inc method:

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()

If you open the console and click the "+" button, you'll see that the reaction ran only once, despite mutating count three times. Why? Because intermediate states are not visible to observers. MobX simply accumulates these changes and delays notifying subscribers until the transaction block is complete.

But this logic breaks when we add asynchronous actions to our action.

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()

The delay function takes a number and returns a Promise that resolves after a setTimeout. Instead of delay, we could write an API request using fetch or any other asynchronous function, but a simple delay is sufficient for illustration.

Now if you open the console and click the "+" button, you'll see that an action is triggered ({ type: "action", name: "inc" }), followed by three reactions. One for each increment.

[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's update batching mechanism breaks because we've "colored" our asynchronous function. Once you launch asynchronous code from a synchronous execution point, you can't return to the call point from the asynchronous code. That is, any steps after await are not in the same tick.

runInAction

To fix this behavior, you can use the runInAction function from the mobx package.

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

If we go back to the console, we'll see that the reaction scheduler and the reaction itself run only once.

The problem is solved, but wrapping code in runInAction every time can be tedious. There are several alternative solutions to this problem.

setTimeout Reaction Scheduler

MobX allows you to configure the behavior of the reaction scheduler. By default, reactionScheduler simply runs the reaction f without any other behavior:

import { configure } from 'mobx'

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

The reactionScheduler configuration can be useful for logging or debugging. Additionally, using setTimeout we can defer the execution of reactions:

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

Equivalent code:

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

This way, the scheduler will defer reactions until all your synchronous changes are complete. In other words, we accumulate changes, wait for them to complete, and then trigger reactions to these changes. This trick allows us to avoid using runInAction and using flow generators instead of async/await.

However, you need to understand that if some third-party code similarly defers tasks using setTimeout instead of executing them immediately, the processing order becomes unpredictable and hard-to-reproduce errors may occur. In particular, from the authors' experience, a deferred reactionScheduler breaks the pusher-js library and creates problems in the React Native environment.

The official MobX position is not to use such tricks, but instead use runInAction or generators.

Generators

Another way to avoid the problem is to use generators:

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()

This approach is the cleanest as it doesn't affect code nesting or the reaction scheduler. However, it has some typing complications. As a solution, you can use this helper function for MobX: https://github.com/mobxjs/mobx/discussions/3195#discussioncomment-2379437

You can vote for this feature on GitHub to have it added to the MobX core.

How to use generators with MobX will be described in a separate article.