I have been using MobX at work for almost a year now, and I’m beginning to form my view on what it is, how it can be used and how it should be used.
When I joined my current team, the application structure was about 10 singleton MobX-stores representing different domain objects and some react components that imported the singletons, rendered their data and called their actions. A singleton is created by exporting an instance instead of a class:
The stores imported each other, and components imported multiple stores. It was fairly simple, but not particularly structured and hard to test. As a fan of functional programming concepts, I thought the contrast to things I am used to, like flux architecture, immutability and pure functions was quite sharp.
A few problems with using singletons were
- Not straightforward to mock.
- Implicit instantiation of stores at import time. Not clear which order stores are created in.
- Many imports in many files.
- Only one instance per runtime, we had to manually reset the state of all stores between tests.
Since then we have moved to the recommended root store pattern, where a root store instantiates all other stores and injects their dependencies along with a reference to the root store itself so stores can access each other via the root store. Instead of importing several singletons, it’s recommended that the root store is provided to components via a React Context. This basically solves the above problems.
That is great! But still I can’t help but think that the contact area with MobX is much larger than it needs to be.
- MobX concepts are spread all over the app.
- Asynchronous actions have cognitive overhead.
Basically my question is: do we actually need to know about the fact that we are using MobX as we add features to an app?
From detecting changes in objects, to domain stores and the root store pattern, is quite a leap. Sure, the docs make clear that this is only a recommended way of coding, but what is really the developers’ minimum contact area with MobX?
The absolute minimum should be one store class with one observable and one action. It turns out to be quite feasible.
With only one observable and one action, it sounds like our store needs to be very generic. For the observable, just put all state in one observable and call it state – not that radical. But how do we manage with only one action? Well, basically by passing a function and some extra arguments to the action. The action passes the current state and extra arguments to the function, and the function returns the new state. The passed function itself does not have to be a MobX action, since it is invoked inside the action of the store.
* Pure in the sense that the function only depends on its parameters. For the purpose of this post, mutating the input is fine as long as it is returned.
Let’s call this store class Bucket, the observable variable state, and the action swap. (Yes – very inspired by Clojures’ atom.)
What about computed values? I will just quote the MobX docs:
“It is important to realize that computed values are only caching points. If the derivations are pure (and they should be), having a getter or function without computed doesn’t change the behavior, it is just slightly less efficient.”
And besides, computed values don’t accept parameters anyway. If you really need them, just extend Bucket and add computed getters. The computed getters can still call pure functions of the state and return the computed value.
What about asynchronous actions? Async code does not have to, and should not be garbled together with state updates (swaps). For example, a service call should be a side effect of a swap. When the response is received, the state is just updated with swap.
Where should side effects happen? To mimic Clojures’ add-watch, we can let users of Bucket register callbacks for when the state changes, using MobX’s autorun:
This makes it simple to perform side effects when the state indicates so. The listener typically checks whether some side effect should be performed, and since listener called in a reactive context, it will be rerun each time any accessed properties change.
What about domain object stores? Since Bucket is completely generic, you just create more instances of Bucket for your domain objects. Although your logic will not live in a store, but in a library of pure functions, it could make sense to split the state by domain into several buckets. If using TypeScript i would suggest making Bucket a generic class.
What about performance? Intuitively, performance should not be affected by coding this way, with the possible exception of computed getters as discussed earlier. But for the fun of it, I forked the js-framework-benchmark and added an implementation. The results show no significant difference from comparable frameworks.
Finally, let’s look at the MobX authors’ view of the role of a store:
The suggested responsibility is indeed better than having logic and state in components, but i think we can do even better. Coding in the way I have discussed:
- Decouples logic from MobX, allowing MobX to become a detail rather than a concept of your app, while still leveraging MobX’s power.
- Simplifies testing.
- Moves emphasis from OO to FP.
- Lets developers focus on JavaScrip instead of frameworks and libraries.
- Does not affect performance.
While exploring these ideas I created a repo proving some of the concepts. I’d especially recommend to have a look at the test files.