Taming the beast that is Mobx State Tree

Bogdan Protsenko
6 min readApr 1, 2021

Mobx State Tree is a powerful state management library, and an established player amongst front end state frameworks:

MobX is one of the most popular Redux alternatives and is used (along with MobX-State-Tree) by companies all over the world, including Netflix, Grow, IBM, DAZN, Baidu, and more.

With an impressive feature set, it is a powerful state system that can enable you to quickly implement functionality in your applications. Unfortunately, usage is low, and opinion is scarce with the ecosystem around Mobx being small compared to something the next largest package, Redux.

In the face of its complexity I found it hard to answer questions around how things should be done as I worked with the library over several years. This article aims to address this problem, sharing my thoughts on:

  • Building flexible, resilient stores that are easy to change and test
  • Features you should be using at the outset
  • Tips, tricks and best practices
  • Common pitfalls to avoid

Here, I will be breaking it down alongside React usage.

Step 1 — Understand MobX

With mobx/mobx-react/mobx-state-tree marshalling bits of functionality all over your app, it’s normal to feel confused about how things fit together. To make use of these libraries effectively (and especially mobx-state-tree) it is important to understand the concepts underlying mobx.

Documentation has improved significantly over the years, and you would do well to do a deep dive into concepts such as observable state, actions, and computed properties.

While the examples in the docs linked above are quite different from what your MST usage will look like, the core concepts for working with (im)mutable, observable state remain largely the same.

Especially reactivity

Reactivity is the driving mechanism behind all Mobx functionality. It can feel like magic, with functions and components re-running automatically whenever a store changes. By not understanding why Mobx decides to re-render an observer wrapped React component, or rerun an autorun callback, your code will be prone to esoteric issues and performance problems on every level.

Understand reactivity and how to debug it, and your experience developing with the library will be magnitudes less painful.

KISS your stores

MST gives you freedom to structure your stores (and the logic within them) however you wish. In my case, as time went on, complexity started multiplying by itself and I found myself thinking about how I can keep it simple, stupid.

Keeping data flow predictable

While MST is very opinionated about how you define and update your models, it doesn’t care as much about how stores integrate with each other. Despite touting the “React for data” slogan it embraces the opposite of a unidirectional data flow, with any node in a tree being capable of accessing parent nodes (helpers such as getParent, getRoot come to mind) and even entirely different branches on the tree with references.

This makes for some very nasty code in unwary hands — the following is a redacted example (which I may or may not have been originally responsible for):

Pretty intense right? On one hand, it’s succinct:

grandChild.remove();
// Gone!

On the other hand, it’s awful for many reasons:

  • How should we remove a grandChild store from the tree? Is it grandChild.remove , child.removeGrandChild or parent.removeGrandChild ? Without properly established conventions, the answer will end up being all 3 depending on which team member is making the commit.
  • This was a pain to follow with 3 stores in a single file. With intermediate stores, files and complicated functions thrown into the mix you have a multi level logical nightmare.
  • Referencing other stores in the tree reduces an individual store’s reusability. If we wanted to test GrandChild as a unit, we would have to mock all the dependencies and behaviour that go into its remove function.

In teams with different levels of experience and separate knowledge domains, weird complexity like this is a natural side effect if there isn’t a convention for flow definition.

The solution here is to establish conventions it early on, to keep data flow simple, predictable and where possible, unidirectional — reduce instances of stores referencing other stores, focus on singular child references only:

This is a huge improvement:

  • Instead of 3 remove functions, we are down to one.
  • Child and GrandChild have been simplified, Child can now be tested and reused in isolation.
  • Logical flow is simpler, with fewer points of failure.

Putting data where it belongs

When iterating on stores I found myself overpopulating them until they were painful to work with. Without fully understanding the power of derived values and store composition, any time I needed to reference data on the store I added it directly to the model.

This means I was responsible for imperatively defining the property’s lifecycle. Take an example store:

This is a bloated model — in the beginning, there were many instances where information I added to models could’ve easily been handled by other MST paradigms. After some time, a common set of questions emerged as we interrogated decisions to add properties to the model:

  • Do we need this information to be serializable (i.e. included in snapshots and when we run .toJSON() on the store)? If yes, then add it to the model.
  • Can this information be derived from existing state? If yes, then use a computed value.
  • Is this information appropriate for volatile state? Volatile state may be an appropriate alternative to model state, especially if it can’t be serialized. If yes, add it to volatile state.
  • Does this information belong on this store? If you are finding that a store is becoming unmanageable, it may be because it is trying to do too much. It may be worth extending the store, and abstracting away new logic to keep things neat.

Refactoring the above example inline with this questioning nets us the following:

The result is objectively easier to reason about and more useful:

  1. 4 model properties to worry about on the Upload store instead of 9.
  2. 4 actions on the Upload store instead of 6, with the remaining actions simplified.
  3. Updates to 3 of the old properties now entirely handled by MST’s computed values.
  4. Temporary upload data, such as the upload ID and Blob stored against the instance for easy reference in volatile state.
  5. URL upload specific logic abstracted away into its own domain under the UrlUpload store.

What about UI state?

Building UI with MST is nice. You pass an observer component a store and any changes to properties referenced by that component will see it magically re-render.

However, if introducing UI state to a model chances are you are mixing it with other, domain specific state. If you find yourself unsure about what to do with a piece of UI state, ask the following:

  • Does it need to be referenced by other stores in the tree? If yes, put it in the store.
  • Does it need to be referenced by other parts of the application? If yes put it in the store.
  • Otherwise, can it safely be handled by the component? If yes, make it local component state. You can even use mobx helpers if you want to keep local component state within Mobx.

Your goal should always be to keep complexity out of your stores, especially when mixing UI and business logic. If you find UI state in stores more often than not, consider defining a specialized UI store to work alongside your business stores.

Use dependency injection

Your stores are responsible for handling data — sometimes they will need to fetch that data, or pass it off to some other service. This is where dependency injection shines.

It enables you to inject a dependency that will be accessible throughout the entire tree with getEnv — perfect for integrating with API’s.

This is a great pattern:

  • It reduces dependencies on specific files or 3rd parties, making it much easier to mock in tests.
  • It clearly separates concerns— if you need to make changes to service logic, they are much less likely to affect the stores.

Afterwards, you can unit test your stores without worrying about the implementation details of your services by simply doing something like:

Wrapping up

  • Keep logic predictable.
  • Keep model complexity low by utilizing views, volatile state and store composition.
  • Make complexity lower by putting UI state in the right place.
  • Ensure stores are only concerned with data by utilizing dependency injection.

Thanks for reading! :)

--

--