Skip to main content

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.

Feature by feature​

Featuremobx-keystonemobx-state-tree
Tree-like structureYes Class models, data models, and plain objects / arrays.Yes Models built with types.model.
Immutable snapshot generationYes getSnapshot, with structural sharing.Yes getSnapshot, with structural sharing.
Patch generationYes onPatches / applyPatches, with inverse patches.Yes onPatch / applyPatch, with reverse patches.
Action serialization / replayingYes onActionMiddleware / applySerializedActionAndTrackNewModelIds, ready for server sync.Yes onAction / applyAction.
Action middlewaresBetter actionTrackingMiddleware makes async (flow) middlewares easier to write.Yes addMiddleware.
Transaction middlewareYes transactionMiddleware or the @transaction decorator.Yes atomic, from mst-middlewares.
Undo manager middlewareYes undoMiddleware, built in, with grouping and attached state.Yes UndoManager, from mst-middlewares.
Flow (async) actionsYes @modelFlow, fully typed through _async / _await.Yes flow generators.
ReferencesYes rootRef / customRef, plus back-references with getRefsResolvingTo.Yes types.reference / types.safeReference.
Frozen dataYes frozen.Yes types.frozen.
TypeScript supportMuch better Self and cross-model references, no late types, no casts.Yes Needs types.late, casts, and Instance / SnapshotIn helpers.
Simple instance / snapshot typesYes Properties hold instances. Snapshots stay at the edges.No Properties accept both, so assignments often need cast.
Simple model life-cycleYes onInit and onAttachedToRootStore, never lazy.No Four hooks, which lazy node creation can delay or skip.
Runtime type validationYes Completely optional.Yes Built into the model types.
No metadata inside snapshotsYes Only with data models, which have no life-cycle hooks.Better Snapshots are always plain data.
Redux compatibility layerYes 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.

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:

mobx-state-tree
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:

mobx-keystone
@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:

mobx-state-tree
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):

mobx-keystone
@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 afterCreate hook might never run it unless the node contents are accessed.
  • You might want to set up an effect (a reaction or the like) only once the model is actually part of your application state.
  • getRoot might 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:

HookWhen it runs
onInitAlways, once the model has been created. There is no lazy initialization.
onAttachedToRootStoreOnce 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.