Comparison with mobx-state-tree
mobx-keystone takes many of its ideas from mobx-state-tree, so most concepts carry straight over. The differences are in how models are typed, how snapshots and instances mix, and how predictable the model life-cycle is.
Plain TypeScript types →Type models with standard annotations. Runtime types are opt-in.
No snapshot / instance casts →Properties hold instances, and snapshots stay at the edges.
this everywhere →Class models with standard MobX computed decorators.
Predictable life-cycle →Two hooks that always run when you expect them to.
Feature by feature
| Feature | mobx-keystone | mobx-state-tree |
|---|---|---|
| Tree-like structure | Yes Class models, data models, and plain objects / arrays. | Yes Models built with types.model. |
| Immutable snapshot generation | Yes getSnapshot, with structural sharing. | Yes getSnapshot, with structural sharing. |
| Patch generation | Yes onPatches / applyPatches, with inverse patches. | Yes onPatch / applyPatch, with reverse patches. |
| Action serialization / replaying | Yes onActionMiddleware / applySerializedActionAndTrackNewModelIds, ready for server sync. | Yes onAction / applyAction. |
| Action middlewares | Better actionTrackingMiddleware makes async (flow) middlewares easier to write. | Yes addMiddleware. |
| Transaction middleware | Yes transactionMiddleware or the @transaction decorator. | Yes atomic, from mst-middlewares. |
| Undo manager middleware | Yes undoMiddleware, built in, with grouping and attached state. | Yes UndoManager, from mst-middlewares. |
| Flow (async) actions | Yes @modelFlow, fully typed through _async / _await. | Yes flow generators. |
| References | Yes rootRef / customRef, plus back-references with getRefsResolvingTo. | Yes types.reference / types.safeReference. |
| Frozen data | Yes frozen. | Yes types.frozen. |
| TypeScript support | Much better Self and cross-model references, no late types, no casts. | Yes Needs types.late, casts, and Instance / SnapshotIn helpers. |
| Simple instance / snapshot types | Yes Properties hold instances. Snapshots stay at the edges. | No Properties accept both, so assignments often need cast. |
| Simple model life-cycle | Yes onInit and onAttachedToRootStore, never lazy. | No Four hooks, which lazy node creation can delay or skip. |
| Runtime type validation | Yes Completely optional. | Yes Built into the model types. |
| No metadata inside snapshots | Yes Only with data models, which have no life-cycle hooks. | Better Snapshots are always plain data. |
| Redux compatibility layer | Yes asReduxStore and connectReduxDevTools. | Yes asReduxStore and connectReduxDevtools. |
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
When not using runtime type checking, mobx-keystone uses standard TypeScript type annotations to declare model data, which lowers the learning curve.
If you do need runtime type checking, mobx-keystone also includes a completely optional type definition / runtime type checking system.
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
In mobx-state-tree you can assign both snapshots and instances to properties, but the properties are typed as instances. That leads to confusing casts and constructs such as:
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 union of the snapshot type and the instance type
setSelected(todo: SnapshotIn<typeof Todo> | Instance<typeof Todo>) {
// note the cast to say it is ok to use a snapshot when
// the property actually expects an instance
self.selected = cast(todo)
},
}))
In mobx-keystone, snapshots usually only show up when calling getSnapshot and fromSnapshot, so the same code gets simpler:
@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 and self, plus standard computed decorators
In mobx-state-tree, code from a previous "chunk" (actions, views) usually has to be accessed through self, while code in the same chunk has to be accessed through this to get proper typings:
const Todo = types
.model({
done: false,
text: types.string,
title: types.string,
})
.views((self) => ({
get asStr() {
// `self`, since the properties come from a previous chunk
return `${self.text} is done? ${self.done}`
},
get asStrWithTitle() {
// `this` for `asStr`, since it comes from the current chunk
return `${self.title} - ${this.asStr}`
},
}))
In mobx-keystone, this always works, and you use the standard MobX computed decorator (including its 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 several 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.
- A submodel with an
afterCreatehook might never run it unless the node contents are accessed. - You might want to set up an effect (a
reactionor the like) only once the model is actually part of your application state. getRootmight not return the root you expect until the model is attached to a parent that is eventually (or never) attached to the proper root.
mobx-keystone solves this by offering only two life-cycle hooks:
| Hook | When it runs |
|---|---|
onInit | Always, once the model has been created. There is no lazy initialization. |
onAttachedToRootStore | Once the model is attached to a root store, so getRoot returns the expected value. It can return a disposer that runs when the model is detached, which makes it the perfect place to set up effects. |
More info in the class models section.