DEV Community

Javier Gonzalez
Javier Gonzalez

Posted on

mobx-keystone, an alternative to mobx-state-tree without some of its pains

Have you ever used mobx-state-tree?

For those who don’t know it, it is an awesome state management library. It offers the ease of use of a mutable tree-like structure that automatically generates structurally shared immutable snapshots, patches, run-time type validation, action serialization and replaying, and on and on. All these treats are what turned me into one of their maintainers and a big proponent of it (and I still am!).

However, nothing is perfect, and after having used it for two big projects, I have seen developers (including myself) struggle with it at times, this is, what I’d like to call, its pain points.

Since these pain points are the main reason mobx-keystone was created, I’d like to show them and then also present the alternative solution taken.

Pain #1: Typescript support

While its Typescript support is much better than what it used to be, there are still areas that haven’t been addressed (and probably with current Typescript capabilities or without an API redesign can’t be at all).

Take for example a self-recursive model, such as a tree or cross-referenced models (models that refer one another). While the library itself supports this kind of structures, it is very hard to make Typescript support those without resorting to weird tricks or just resorting to any. Not to mention that the typings are so complex that new Typescript releases risk breaking them (although fixes do come fast).

mobx-keystone was created with an “Typescript first” mentality, up to a point where you don’t even need to use anything else to declare types if you don’t require run-time type checking. For example, properly typing a tree made of self-recursive nodes is just:

// self recursive model
@model(myApp/TreeNode)
class TreeNode extends Model({
  children: prop<TreeNode[]>(() => [])
}) {}

And cross-referencing models is just as easy:

// cross-referenced models
@model("myApp/A")
class A extends Model({
  b: prop<B | undefined>()
}) {}

@model("myApp/B")
class B extends Model({
  a: prop<A | undefined>()
}) {}

In other words, mobx-keystone, when not using run-time type checking, uses standard Typescript type annotations to declare the data of models, therefore lowering the learning curve. However, if you need run-time type checking, mobx-keystone includes a completely optional type definition / run-time type checking system as well.

Pain #2: Instances, input snapshots, output snapshots, casts…

In mobx-state-tree it is possible to assign snapshots to properties, as well as actual instances, but the actual type of properties are instances, which leads to confusing casts and constructs such as:

// mobx-state-tree code

const Todo = types.model({
  done: false,
  text: types.string
})
.actions(self => ({
  setText(text: string) {
    self.text = text
  },
  setDone(done: boolean) {
    self.done = done
  }
}))

const RootStore = types.model({
  selected: types.maybe(Todo))
})
.actions(self => ({
  // note the usage of an intersection of the snapshot type
  // and the instance type
  setSelected(todo: SnapshotIn<typeof Todo> | Instance<typeof Todo>) {
    // note the usage of cast to indicate that it is ok to use
    // a snapshot when the property actually expects an instance
    self.selected = cast(todo)
  }
}))

Note how the setSelected action, can actually take an input snapshot (plain Javascript object) or instance (instantiated mobx-state-tree object) as input, plus a cast to make Typescript get along well with plain Javascript objects being automatically converted into instances upon assignation. Then just imagine having to explain that to another developer new to the technology.

In mobx-keystone snapshots are usually only expected when dealing with getSnapshot and fromSnapshot, this is, only when actually dealing with serialization scenarios. This leads to less confusion and more explicit usage:

// mobx-keystone code
@model("myApp/Todo")
class Todo extends Model({
  done: prop(false),
  text: prop<string>(),
}) {
  @modelAction
  setText(text: string) {
    this.text = text
  }
  @modelAction
  setDone(done: boolean) {
    this.done = done
  }
}

@model("myApp/RootStore")
class RootStore extends Model({
  selected: prop<Todo | undefined>(undefined),
}) {
  @modelAction
  setSelected(todo: Todo | undefined) {
    this.selected = todo
  }
}

Pain #3: this, self, action chunks, views chunks…

When using mobx-state-tree with Typescript, in order to get proper typings, code from a previous “chunk” (actions, views, etc.) has to be accessed using self, while code in the same “chunk” has to be accessed using this.

// mobx-state-tree code

const Todo = types
  .model({
    done: false,
    text: types.string,
    title: types.string,
  })
  .views(self => ({
    get asStr() {
      // here we use self since the properties
      // come from a previous chunk
      return `${self.text} is done? ${self.done}`
    },
    get asStrWithTitle() {
      // here we use this for asStr since it
      // comes from the current chunk
      return `${self.title} - ${this.asStr}`
    },
  }))

In mobx-keystone, this is the only way to access the current instance, there’s no need to artificially separate action chunks from view chunks, plus the standard mobx computed decorator can be used, making the transition from plain mobx “classes” much easier to grasp.

// mobx-keystone code

@model("myApp/Todo")
class Todo extends Model({
  done: prop(false),
  text: prop<string>(),
  title: prop<string>(),
}) {
  @computed
  get asStr() {
    return `${this.text} is done? ${this.done}`
  }
  @computed
  get asStrWithTitle() {
    return `${this.title} - ${this.asStr}`
  }
}

Pain #4: Model life-cycle

mobx-state-tree has a couple of life-cycle hooks (afterCreate, afterAttach, beforeDetach, beforeCreate) that might or might not trigger when you think they should due to the lazy initialization of nodes.

For example, you might create a sub-model with an afterCreate hook, but it might never be actually executed unless the node contents are accessed (due to lazy initialization). Maybe you might want to set-up an effect (reaction or the like), but you only want that effect to work after it actually becomes part of your application state. Likewise you might want to access getRoot to access the root model, but they might actually not give the value you expect until they are attached to a parent which is eventually (or not) attached to the proper root.

mobx-keystone solves this by only offering two life-cycle hooks, onInit, which is always called once the model has been created (and since there's no lazy-initialization they will always be), and onAttachedToRootStore (plus an optional disposer that gets executed when it is detached), which gets called once the model gets attached to the proper root node (a root store), thus ensuring that at that point getRoot will return the expected value and makes it a perfect place to set up effects.

Pain #5: And them some more

References in mobx-state-tree were designed to be transparent to the user. Maybe way too transparent, up to a level where there’s no way, for example, to get the parent of a reference node. In mobx-keystone references are explicit objects, thus making for example this use-case trivial.

Action middlewares were not made with async (flows in mobx lingo) in mind, which makes them kind of hard to use in such cases. mobx-keystone make sure in their middlewares that (async) flows are as easy to use as sync actions.

Summary

This article did not intend in any way to bash mobx-state-tree (again, it is awesome!), but just to expose some of its pain points. Maybe you are super happy with it and it fits your app perfectly. That’s fine!
But if you have also felt some of those pain points I suggest you to head over to https://mobx-keystone.js.org and give it a try!

Top comments (0)