A few weeks ago Bertalan Miklos wrote a very interesting blog in which he compared the proxy based NX-framework with MobX. That blog is not only interesting because it proves the viability of proxies, but even more because it touches some very fundamental concepts in MobX and transparent reactivity (automatic reactivity) in general. Concepts I probably did not elaborate enough on so far. So let me share the mental model behind some of the unique features of MobX.
The article touches a very remarkable feature (imho) of MobX: in MobX all derivations are run synchronously. Which is quite unusual. Most UI frameworks don’t do this (if any at all). (Reactive stream libraries like RxJS run by default synchronous as well, but they lack transparent tracking so the situation is not entirely comparable).
Before starting MobX, I did quite some research on how existing libraries were perceived by developers. Frameworks like Meteor, Knockout, Angular, Ember and Vue all expose reactive behavior similar to MobX. And they exist already for a long time. So why did I built MobX? When digging through the issues and comments of people that were unhappy with these libraries, it occurred to me that there was one recurring theme which caused the chasm between the promise of reactivity and the nasty issues one had to deal with in practice.
That recurring theme is “predictability”. If a framework runs your code twice, or with a delay, it becomes hard to debug it. Or to reason about it. Even ‘simple’ abstractions like promises are notoriously hard to debug due to their async nature.
I believe unpredictability to be, rightfully, one of the most important reasons for the popularity of Flux patterns and especially Redux: it addresses exactly this issue of predictability when scaling up. There is no magic scheduler at work.
MobX however took another approach; instead of leaving behind the whole concept of automatically tracking and running functions, it tries to address the root causes. So that we can still reap the benefits of this model. Transparent reactivity is declarative, high-level and concise. It does this by adding two constraints:
Constraint 1. addresses so called “double runs”. It makes sure that if one derived value depends on another derived value, these derivations run in the right order. So that none of them accidentally read a stale value. How that exactly works is described in great detail in an earlier blog post.
The second constraint; derivations are never stale; is a lot more interesting. Not only does it prevent so called “glitches” (temporary inconsistencies). It requires a fundamental different approach to scheduling derivations.
So far UI libraries have always taken the easy way out when it comes to scheduling derivations: Mark derivations as dirty, and re-run them on the next tick after all the state has been updated.
This is simple and straight forward. It is a fine approach if your only concern is updating the DOM. The DOM usually lags a bit ‘behind’ and we hardly read data from it programmatically. So temporary staleness is not really an issue. Yet temporary staleness kills the general applicability of a reactive library. Take for example the following snippet:
const user = observable({firstName: “Michel”,lastName: “Weststrate”,
// MobX computed attributefullName: computed(function() {return this.firstName + " " + this.lastName})})
user.lastName = “Vaillant”sendLetterToUser(user)
The interesting question now is: When the sendLetterToUser(user)
runs, will it see an updated or a stale version of the fullName
of the user? When using the MobX the answer is always “updated”: Because MobX guarantees that any derivation is updated synchronously. This does not only prevent a lot of nasty surprises, it also makes debugging much simpler as a derivation always has the causing mutation in its stack.
So if you are wondering why a derivation is running, simply walking up the stack brings you back to the action that caused the derivation to be invalidated. If MobX would be using async scheduling / processing of derivations these advantages are lost. The library would not be as generally applicable as it is know.
When I started on MobX, there was a lot of skepticism on whether this could be done efficiently enough: Ordering the derivation tree and running derivations with each mutation.
But here we are, with a system that is out of the box often more efficient than manually optimized code, as reported for example here and here.
Yes, there is a small payoff to pay; mutations should be grouped in transactions to process multiple changes atomically. Transactions postpone the execution of derivations to the end of the transaction, but still runs them synchronously. Even cooler; if you use a computed value before the transaction has ended, MobX will ensure you get an updated value of that derivation nonetheless!
In practice nobody uses transactions explicitly, they are even deprecated in MobX 3. Because actions apply transaction automatically. Actions are conceptually much nicer; an action indicates a function that will update state. They are the inverse of reactions, which respond to state updates.
The conceptual relation between actions, state, computed values and reactions
Another thing MobX focuses strongly on is the separation between values that can be derived (computed values), and side effects that should be triggered automatically if state changes (reactions). The separation of these concepts is very important and fundamental to MobX.
Example derivation graph. Observable state(blue), computed values (green) and reactions (red). Computed values will suspend (light green) if not observed (indirectly) by a reaction. MobX makes sure that each derivation runs only once and in the most optimal order after a mutation.
Computed values should always be preferred over reactions.
For several reasons:
In the end, every software system needs side effects. For the simple reason we always need to bridge from reactive to imperative code. For example to make network requests or flush the DOM. However, often these reactions can be put away in clean abstractions like React observer
components.
So MobX goes great lengths to make sure stale values can never be observed, and derivations do not run more often then one would expect. In fact, computations are not even run at all if nobody is actively observing. In practice this makes all the difference. There is often some initial resistance to MobX, as the concepts remind people of the unpredictable behavior of MVVM frameworks. However, I believe the semantical clarity of actions, computed values and reactions, the fact that no stale values can be observed, the fact that all derivations run on the same stack as the causing action, makes all the difference here.
MobX is used heavily in production, and therefor has made a commitment to run on every ES5 environment. This makes it possible to target practical any browser with MobX, but also makes it unacceptable at this moment to rely on Proxies. For this reason MobX has some rough edges, like not fully supporting expando properties and using faux-arrays. It is no secret that the plan always have been to ultimately move to a Proxy based implementation. MobX 3 already has some changes that prepare for proxy usage, and the first opt-in Proxy based features can be expected soon. However the core will remain Proxy free until the vast majority of devices and browsers support them.
Regardless the future migration to proxies, the modifiers / shallow observable concept will remain in some form in MobX.
The reason for the modifiers mechanism is not performance; it is interoperability.
As long as all data in the state of your application is under your control, auto-observability is very convenient. MobX started with just that. But at some point you discover that the world is not as ideal as you want it to be. In each application there are many libraries, each playing their own role, and each making their own assumptions. Modifiers and shallow collections were introduced in MobX to be able to make it clear what data MobX is allowed to manage.
For example, sometimes you need to store references to external concepts. However, automatic conversion to observables is often not desirable for objects that are managed by some external library already (for example JSX or DOM elements). It can easily interfere with the internal assumptions of that library. One can find quite some issues in the MobX issue tracker where turning objects unintentionally into observables lead to unexpected behavior. Modifiers are not meant to signal “please make this fast”, but to signal “only observe references to the object, consider the object itself a black box as it is not under our control”.
This concept fits working with immutable data structures very nicely as well. It makes it possible to create, for example, an observable map of messages, where the messages themselves are considered immutable data structures.
A second problem is that auto-observable collections always create ‘clones’, which is not always acceptable. Proxies always produce a new object and they work only in “one direction”. So if a the original of a proxied object is modified by the library that originally distributed it, proxies won’t detect that change. To illustrate:
const target = { x: 3 }const proxy = createObservableProxy(target)
observe(() => {console.log(proxy.x)})
target.x = 4// proxy.x is now 4, but no log statement will be written, as the proxy setter hook is not fired!
Modifiers provide the necessary flexibility to deal with these situations. For now, since MobX uses property descriptors, it can actually enhance existing objects, so that data mutation can work in two directions if the need really arises.
That being said, the way NX produces observable proxies on the fly during reads is super interesting. I’m not sure how it handles referential transparency yet, but so far it seems a very smart thing to do. Avoid modifiers by reading / writing through $raw
is a very interesting approach. I’m not sure if it is cleaner to read or write, but it is definitely saves the introduction of some concepts like shallow which is great.
A small note has to be made on the semantics of untracked
, unlike suggested, it doesn’t relate to $raw
updates in NX. In MobX there is simply no way to update data without notifying observers. Allowing this deliberately introduces the possibility of stale data in an application. Which goes against the principles of MobX. People sometimes think they need such a mechanism, but I never encountered a real life use case where there was not a conceptually cleaner solution.
untracked
works the other way around: It is not about undetectable writes. Instead, it just causes reads to be untracked. In other words, it is way of saying that we are not interested in any future updates of the data we use. Like transaction
, nobody uses this API in practice, but it is a mechanism baked into actions because it makes conceptually a lot of sense: Actions run in response to a (user) event, not in response to state changes, so they shouldn’t be tracking which data they consume. That is what reactions are for.
MobX is designed to be a generally applicable reactivity library. It’s not just an utility to re-render the UI at the right moment.
Instead, it generalizes the concept of efficiently working (both in terms of performance and effort) with data that is just the project of some other data. At Mendix for example MobX is used in backend processes as well. Running derivations synchronously and the separation between computed values and reactions is very fundamental to MobX. It leads to a much clearer mental picture of the application state.
Finally, nx-observe proves that proxies are very viable foundation for a transparent reactive programming library. Both conceptually and performance wise.