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:
Feature | mobx-keystone | mobx-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 |
- Includes an improved action tracking middleware that makes it easier to create middlewares for flow (async) actions.
- Support for self-model references / cross-model references / no need for late types, no need for casting, etc.
- Runtime type checking / type definitions are completely optional in
mobx-keystone
. - 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:
onInit
which is always called once the model has been created (and since there's no lazy initialization they will always be)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 pointgetRoot
will return the expected value and makes it a perfect place to set up effects (more info in the class models section)