Skip to main content

Comparison with mobx-state-tree

This library is very much like mobx-state-tree and takes lots of ideas from it, so the transition should be fairly simple. There are some trade-offs though, as shown in the following chart:

Featuremobx-keystonemobx-state-tree
Tree-like structure
Immutable snapshot generation
Patch generation
Action serialization / replaying
Action middleware support (1)
- Atomic/Transaction middleware
- Undo manager middleware
Flow action support
References
Frozen data
TypeScript support (2)
Simplified instance / snapshot type usage
Simplified model life-cycle
Runtime type validation (3)
No metadata inside snapshots (4)
Redux compatibility layer
  1. Includes an improved action tracking middleware that makes it easier to create middlewares for flow (async) actions.
  2. Support for self-model references / cross-model references / no need for late types, no need for casting, etc.
  3. Runtime type checking / type definitions are completely optional in mobx-keystone.
  4. Only when using data models, although they lack life-cycle support.

TypeScript improvements

mobx-state-tree has some limitations when it comes to TypeScript typings, which mobx-keystone tries to overcome.

If you know TypeScript you already know how to type models

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

Self-recursive and cross-referenced models

Self-recursive or cross-referenced models are impossible (or at least very hard) to properly type in mobx-state-tree, but they become trivial with mobx-keystone.

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

// 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>() }) {}

Simpler instance / snapshot type usage

Another area of improvement is the simplification of the usage of snapshot vs. instance types. 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 a union 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)
},
}))

In mobx-keystone snapshots are usually only expected when dealing with getSnapshot and fromSnapshot, so it leads to a simpler usage:

@model("myApp/Todo")
class Todo extends Model({
done: prop(false).withSetter(),
text: prop<string>().withSetter(),
}) {}

@model("myApp/RootStore")
class RootStore extends Model({
selected: prop<Todo | undefined>(undefined).withSetter(),
}) {}

Less confusion between this/self usages - use of standard computed decorators

Usually in mobx-state-tree code from a previous "chunk" (actions, views) has to be accessed using self, while code in the same "chunk" has to be accessed using this to get proper typings:

// 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 can always be used, plus the standard computed MobX decorator (including extra options):

@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}`
}
}

Simplified 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 submodel 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 call getRoot to access the root model, but it might actually not give the value you expect until the model is 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:

  1. onInit which is always called once the model has been created (and since there's no lazy initialization they will always be)
  2. 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 (more info in the class models section)