Is mobx magic?
When I was first introduced to mobx about a year and half ago, you probably could have convinced me that it was magic. The idea that my react components knew how to update when I mutated properties on an object was bizarre. Since then, I’ve been introduced to a few techniques and javascript features that made it click. I don’t know how mobx actually works, but I can now see how it’s possible.
The goal of this post is to introduce you to those techniques and features so you can also get a feel for how it’s possible. This means that I won’t be focusing on things like performance or async, just the basics. I’ll gradually introduce more & more “magical” parts of the library until we the get to the most bewildering part. By the end of this article, you should understand how the following snippet is possible:
const library = mobx.observable.shallow({})
mobx.autorun(() => console.log(library.name))
library.name = 'mobx'
// log: undefined
// log: mobxIf you’re not familiar with mobx, the docs have a fantastic 10 minute introduction to the library. If that didn’t exist I would want to give an explanation here but there’s no way I’m going to beat that.
The most basic thing we can do with mobx is observe …
observables. By “observing” a value we are essentially subscribing
to updates to that value. When I say “observe” I mean “provide a
function that is called when an observable changes”. For example,
we can observe a box:
const box = mobx.observable.box(true);
box.observe(value => console.log(value));
box.set(false);
// log: falseNote: for the sake of simplicity I have changed the
signature of observer from
{ newValue: boolean, ... } -> void to
boolean -> void
Hopefully this doesn’t seem too surprising. This is very similar to the “event emitter” pattern that most javascript programmers will be familiar with. We can emulate this behaviour with a single function:
function box(initialValue) {
let value = initialValue
const observers = new Set()
return {
get: () => value,
set: (newValue) => {
value = newValue
for (const observer of observers) {
observer(newValue)
}
},
observe: (observer) => {
observers.add(observer)
}
}
}Simple enough. Let’s introduce a little more magic. Mobx
exposes a function called autorun, which is very
similar to observable.observe. The major difference
with autorun is that unlike observe,
there is no explicit dependency between the observable and the
observer! In other words, mobx figures out what observables
an observer depends on & calls the observer any time those
observables are updated. This might be a little confusing, so an
example will help:
const isActive = mobx.observable.box(true);
mobx.autorun(() => console.log(isActive.get()));
isActive.set(false);
// log: true
// log: falseNote: if you’re familiar with mobx you might have noticed
that I’m not updating box inside an action. I’ve
omitted actions since they’re out of scope for this article, and
not strictly necessary. You should use them in your app
however.
Two interesting things have happened:
- Mobx somehow knew that the function passed to
autorundepends onbox& re-ran it whenboxwas updated - In contrast to
box.observe, the callback was called twice
This works because the .get method registers
the current observer as being dependent on
*box*. This means that .get is not pure, like
we would normally assume for a getter. This also means that there
is some global state inside the mobx library.
We can emulate this with a global variable and two functions. Let’s update what we’ve got so far to support this:
let currentObserver = undefined
function reaction(observer) {
currentObserver = observer
observer()
currentObserver = undefined
}
function box(initialValue) {
let value = initialValue
const observers = new Set()
return {
get: () => {
if (currentObserver !== undefined) {
observers.add(currentObserver)
}
return value
},
set: (newValue) => {
value = newValue
for (const observer of observers) {
observer(newValue)
}
},
observe: (observer) => {
observers.add(observer)
},
}
}Two things have changed:
- When
autorunis called with an observer, thecurrentObserverglobal variable is updated with that observer. It then runs the observer and then resetscurrentObserver box.getchecks ifcurrentObserveris set. If it is, it adds it as an observer
To figure out what observables an observer depends on,
autorun must call the observer straight away
to get a chance to register them before they’re updated. This is
why we saw two things logged to the console rather than one. When
we interact with those observables, they take note of the current
observer & call that observer when they are updated.
So far this isn’t too strange. Side effects inside a getter, while unusual, aren’t hard to understand. Things get a little weirder once we start observing objects.
let point = mobx.observable.shallow({ x: 0, y: 0 })
mobx.autorun(() => console.log({ x: point.x, y: point.y }))
point.y = 1
// log: { x: 0, y: 0 }
// log: { x: 0, y: 1 }Somehow mobx is able to figure out that you’re accessing plain
properties on an object. This should be impossible if we’re not
calling a method, right? That would be true is these really were
plain properties. This isn’t the case here – mobx is “upgrading”
these properties to something called “property
accessors”. These provide a way to customise setting and
getting properties. To create an accessor, we can put the
get or set keywords in front of a
method. For example, let’s log something every time a property is
accessed:
const o = {
get property() {
console.log('hello!')
return 1
}
}
o.property
// log: hello!Unfortunately, property accessors alone aren’t enough to
emulate the behaviour of mobx.observable.shallow. We
need to be able to create these property accessors
dynamically, based on the supplied object, without
relying on the user to create the accessors themselves. Each of
the keys present in the original property need to be “upgraded” to
property accessors.
Let’s see why this won’t work. In the following snippet we
iterate over the keys of a source object and use
Object.assign to attempt to add that accessor to a
target object:
const source = { foo: 'foo', bar: 'bar' }
const observable = {}
for (const key in source) {
Object.assign(observable, {
get [key]() {
console.log('getting ' + key)
return false
},
})
}
// log: getting foo
// log: getting bar
observable.foo
observable.bar
// nothing logged to console ☹️The accessors are run while we’re copying over the properties
to observable, but not when we eventually access
them. We can assume this happens because
Object.assign will be running the accessor
internally, something like
target.prop = source.prop.
Ok, Object.assign is out because the property
accessor is lost when copying the properties over. What about a
really long prototype chain? See
“Object prototypes” on MDN if you’re unfamiliar with how
inheritance works in JS
> const getterWithKey = (key) => ({
get [key]() {
console.log('getting ' + key)
return false
}
})
> const foo = getterWithKey('foo')
> const bar = getterWithKey('bar')
> bar.__proto__ = foo
Object <[Object: null prototype] {}> { foo: [Getter] }
> const baz = getterWithKey('baz')
{ baz: [Getter] }
> baz.__proto__ = bar
Object <Object <[Object: null prototype] {}>> { bar: [Getter] }
> baz.baz
getting baz
false
> baz.bar
getting bar
false
> baz.foo
getting foo
falseThis works! It’s disgusting, but it works. The performance
implications are pretty bad though: any time your want to access a
property, you need to walk up N prototypes (where N is the number
of properties) in the worst case. Accessing properties on an
observable object definitely is not O(N) so there has
to be another way.
Note: even though I did say that I wouldn’t talk about performance, this solution is so egregious that I had to reject it
Is there another way to define property accessors? Underneath the hood, properties are defined using something called descriptors. From MDN:
Property descriptors present in objects come in two main flavors: data descriptors and accessor descriptors. A data descriptor is a property that has a value, which may or may not be writable. An accessor descriptor is a property described by a getter-setter pair of functions. A descriptor must be one of these two flavors; it cannot be both.
This means that so called “property accessors” are actually
just syntactic sugar for accessor descriptors. We can use
Object.defineProperty to manually create these
descriptors, like so:
Object.defineProperty(o, 'k', {
get: () => value,
set: (newValue) => (value = newValue),
});Let’s update our script to take advantage of this:
let currentObserver = undefined
function autorun(observer) {
currentObserver = observer
observer()
currentObserver = undefined
}
function shallow(source) {
if (
source.__proto__ != null
|| source.__proto__ === Object.prototype
) {
throw new Error(
'can\'t make an observer from an object with a prototype',
)
}
const values = {}
const observers = new Map()
const makeGetter = (key) => () => {
if (currentObserver != null) {
let observersForKey = observers.get(key)
if (observersForKey == null) {
observersForKey = new Set()
observers.set(key, observersForKey)
}
observersForKey.add(currentObserver)
}
return values[key]
}
const makeSetter = (key) => (newValue) => {
if (observers.has(key)) {
for (const observer of observers.get(key)) {
observer(newValue)
}
values[key] = newValue
}
}
const observable = {}
// we can look at all the keys since we know this object
// does not have a prototype
for (const key in source) {
values[key] = source[key]
Object.defineProperty(observable, key, {
get: makeGetter(key),
set: makeSetter(key),
enumerable: true,
configurable: false,
})
}
return observable
}
function box(initialValue) {
let value = initialValue
const observers = []
return {
get: () => {
if (currentObserver !== undefined) {
observers.push(currentObserver)
}
return value
},
set: (newValue) => {
value = newValue
for (const observer of observers) {
observer(newValue)
}
},
observe: (observer) => {
observers.push(observer)
},
}
}For each of the properties in the object that we want to upgrade to an observable, we create an accessor descriptor that performs the familiar side effects of registering & notifying observers.
If we were still on mobx v4, this would be the end. As of mobx v5 we can take this a step further:
const library = mobx.observable.shallow({})
mobx.autorun(() => console.log(library.name))
library.name = 'mobx'
// log: undefined
// log: mobxNote: this is the code snippet I promised you’d understand at the start of the article
The “magic” thing here is that mobx is able to react to
properties being mutated even when it doesn’t know about the
properties in the first place. In the previous step we needed to
enumerate all the keys to set up the shallow object (via
for key in object). In this example, the property
.name is only used after the observable is
created.
The secret sauce here is a new javascript feature called Proxies. MDN has a good intro to proxies, but in short they provide the ability to customise fundamental object behaviour, including property lookup and property assignment. The following example logs every time a property is read:
const o = new Proxy({ foo: 1 }, {
get(object, key) {
console.log('getting ' + key)
return object[key]
}
})
o.foo
// log: getting foo
o.bar
// log: getting barThis will actually be simpler than the last version. We just
need to create a handler that performs side effects
every time we read or write a property:
let currentObserver = undefined
function autorun(observer) {
currentObserver = observer
observer()
currentObserver = undefined
}
function shallow(source) {
if (
source.__proto__ != null
&& source.__proto__ !== Object.prototype
) {
throw new Error(
'can\'t make an observer from an object with a prototype',
)
}
const observers = new Map()
const addObserver = (key, observer) => {
let observersForKey = observers.get(key)
if (observersForKey == null) {
observersForKey = new Set()
observers.set(key, observersForKey)
}
observersForKey.add(observer)
}
const handler = {
get: (object, key) => {
if (currentObserver != null) {
addObserver(key, currentObserver)
}
return object[key]
},
set: (object, key, value) => {
object[key] = value
for (const observer of (observers.get(key) || [])) {
observer()
}
},
}
return new Proxy(source, handler)
}
function box(initialValue) {
let value = initialValue
const observers = []
return {
get: () => {
if (currentObserver !== undefined) {
observers.push(currentObserver)
}
return value
},
set: (newValue) => {
value = newValue
for (const observer of observers) {
observer(newValue)
}
},
observe: (observer) => {
observers.push(observer)
},
}
}I hope you now have a feel for how the functionality that mobx provides is possible. To drive it home, let’s do a quick summary:
- When you call
mobx.autorun, a globalcurrentObservervariable is set to the supplied callback - If any observable objects are used inside the callback, the object will register the callback as an “observer” of itself
- Any time an observable object is updated, all observers are notified
- Mobx v5 uses proxies to intercept all property reads & writes