MobXSneak Peek Behind the Scenes of MobX
Hameed Damee
Pau Ramon Revilla
Stemming from the previous three articles on MobX, the following facts can be highlighted about MobX:
- It is minimalistic.
- It supports the mutation of states through actions.
- It supports reactive programming or is built around reactive programming.
In this chapter, we would focus on the last two points. Why is mutation allowed in MobX, though it is considered an anti-pattern? How does Mobx handle this mutation? What is reactive programming in the MobX context? As we try to answer this, let’s start by looking at data from the perspective of identity and equality.
Identity and Equality in Data
Data can be categorized either as primitives or data structures. Primitive data types are numbers, booleans, and other simple data types.
For primitives, identity is equal to equality. That is, 1
is equal to 1
, true
is equal to true
. And for JavaScript, “Tayo”
is equal to “Tayo”
. In the same vein, “Tayo”
is not equal to “Oyat”
, as the same characters are spelled backward. So for primitives, identity and equality are simple and straightforward.
However, with data structures, it’s a bit more complicated.
1
2
3
4
5
6
7
8
9
10
11
12
13
const studentA = {
name: "Tolu",
age: 3
}
const studentB = {
name: "Tolu",
age: 3
}
console.log(studentA === studentB)
// false
The log above will always return false. This is because when we compare the identity of data structures, we are comparing two pointers as succinctly explained in this article. This difference between identity and equality is solved in some programming languages with immutable data structures. Additionally, it can be solved using the reactive programming paradigm (on which MobX is based on). Therefore, how does MobX solves this problem?
Preparing State for Subscription Using Observable
The observable
, as you might have noticed from the previous articles in this series, wraps data and gives them observability. That is, observable
makes data available for subscription.
1
2
3
4
5
6
7
8
import { observable } from 'mobx';
const studentA = observable({
name: "Tolu",
age: 3
});
console.log(studentA)
Logging student, we will notice that it is a Proxy. It is more than a JavaScript object!
Next, we need to be able to listen to the changes to the state and this is possible through autorun
, more about it below.
Subscribing to State Changes
To ensure that we listen to or subscribe to changes to the state, we would need reactions
. One of the two common reactions in MobX is autorun
and runInAction
. In our case, since we have prepared studentA
with the observable wrapper, we can listen to changes to it with the autorun
1
2
3
4
5
6
7
8
9
10
import { observable, autorun } from 'mobx';
const studentA = observable({
name: "Tolu",
age: 3
});
autorun(() => {
console.log('change detected with autorun:', studentA.name)
})
With the above setup, if we try to change the name
for studentA
, we should get the following:
1
2
3
studentA.name = 'Sergio'
// change detected with autorun: Sergio
As seen above, autorun
, having been subscribed to name
, has detected a change to the name
prop. It is interesting to know that autorun
will only detect changes to what it is listening to. MobX subscriptions work by reading data. It assumes that if you have read a piece of data, it means you are interested in hearing more about it. This automatic subscription feels like magic or wrong but it is actually the secret sauce to MobX. Also, this ensures that you don’t over or under-subscribe (like it happens with redux or with hooks dependencies):
1
2
3
studentA.age = 14;
// change is not detected in this case because autorun is not listening to age
This subscription is synchronous. This means that the autorun
is triggered immediately after every change to the subscribed state. To understand, consider the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { observable, autorun } from 'mobx';
const studentA = observable({
name: "Tolu",
age: 3
});
autorun(() => {
console.log('change detected with autorun:', studentA.name)
})
console.log("Before change to Xavi")
studentA.name = "Xavi";
console.log("Before change to Aishat")
studentA.name = "Aishat";
console.log("Before change to Robert")
studentA.name = "Robert";
// The order of logs is as follows:
// change detected with autorun: Tolu
// "Before change to Xavi"
// change detected with autorun: Xavi
// "Before change to Aishat"
// change detected with autorun: Aishat
// "Before change to Robert"
// change detected with autorun: Robert
Asynchronous State Subscription
As shown above, autorun
works synchronously. Now imagine that I made changes to the name
of the student, and then changed it back to the original state. Ideally, we should have our component rendered twice, but that is not what happens. So how does MobX prevent this behavior? runInAction
.
runInAction
is asynchronous. Consider the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { observable, autorun } from 'mobx';
const studentA = observable({
name: "Tolu",
age: 3
});
autorun(() => {
console.log('change detected with autorun:', studentA.name)
})
runInAction(() => {
console.log("Before change to Xavi")
studentA.name = "Xavi";
console.log("Before change to Aishat")
studentA.name = "Aishat";
console.log("Before change to Robert")
studentA.name = "Robert";
})
// The order of logs this time is as follows:
// change detected with autorun: Tolu
// "Before change to Xavi"
// "Before change to Aishat"
// "Before change to Robert"
// change detected with autorun: Robert
With the runInAction
, the log only prints the last change to the name, unlike autorun
which does it synchronously. This is particularly useful, for example, when you want to have transaction semantics, this means, batching several mutations and only reacting once. In a react application, this can save you lots of re-renders and you may want to avoid intermediate states re-rendering the component.
State Computations
So far, we have only seen side effects. The biggest advantage of using any reactive framework is having derived computations, which is just a fancy word for caching if you think about it. Did I ever mention that cache invalidation is hard? Well, caching is a side-effect, and we can see that MobX is good and efficient in this regard. Let's see it in action:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { observable, computed } from 'mobx';
const students = observable([
{
name: "Tolu",
age: 3
},
{
name: "Femi",
age: 5
}
]);
const studentAge = computed(() => (
students.find(student => student.name == 'Tolu')?.age
), { keepAlive: true })
console.log(studentAge.get()) // => 3
students[0].age = 10
console.log(studentAge.get()) // => 10
students[1].age = 6
console.log(studentAge.get()) // => 10, no re-recompute
students[0].name = 'Lisa'
console.log(studentAge.get()) // => null
students.push({ name: 'Tolu', age: 12 })
console.log(studentAge.get()) // => 12
As we can see in the example above, computed
returns an observable. This means that you can combine computeds
and only read them whenever you want the reaction to happen.
There is only one gotcha: MobX will only cache computeds
when they are inside a reaction. MobX will clean the subscriptions for you automatically when leaving the reaction. This is why in the example above, I added the keepAlive: true
, which ensures that the cache will leave forever. This is usually not a problem when using it in conjunction with React, as we will see later on.
Conclusion
MobX is sweet! We know that state mutation is possible within MobX and have established tools like observable
, autorun
, and runInAction
which MobX uses behind the scenes to handle state mutation effectively following the reactive programming paradigm. It should not be surprising that we use MobX at Factorial 😉 so in the last part of this series 😅, I will be highlighting how MobX ranks when compared to Redux, hence the reason why we use it.