Package Exports
- mobx-state-tree
- mobx-state-tree/package
This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (mobx-state-tree) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.
Readme
mobx-state-tree
Opinionated, transactional, MobX powered state container
Installation
- NPM:
npm install mobx-state-tree --save
- Yarn:
yarn add mobx-state-tree
- CDN: https://unpkg.com/mobx-state-tree/mobx-state-tree.umd.js (exposed as
window.mobxStateTree
) - JSBin playground (without UI)
Philosophy
mobx-state-tree
is a state container that combines the simplicity and ease of mutable data with the traceability of immutable data and the reactiveness and performance of observable data.
Put simply, mobx-state-tree tries to combine the best features of both immutability (transactionality, traceability and composition) and mutability (discoverability, co-location and encapsulation) based approaches to state management; everything to provide the best developer experience possible. Unlike MobX itself, mobx-state-tree is very opinionated on how data should be structured and updated. This makes it possible to solve many common problems out of the box.
Central in MST (mobx-state-tree) is the concept of a living tree. The tree consists of mutable, but strictly protected objects enriched with runtime type information. From this living tree, (structurally shared) snapshots are generated automatically.
(example)
By using the type information available; snapshots can be converted to living trees and vice versa with zero effort. Because of this, time travelling is supported out of the box, and tools like HMR are trivial to support.
(example)
The type information is designed in such a way that it is used both at design- and run-time to verify type correctness (Design time type checking is TypeScript only atm, Flow PR's are welcome!)
(screenshot)
Because state trees are living, mutable models actions are straight-forward to write.
(Example)
But fear not; actions have many interesting properties. By default trees cannot only be modified by using an action that belongs to the same subtree. Furthermore actions are replayable and can be used as means to distribute changes (example).
Moreover; since changes can be detected on a fine grained level. JSON patches are supported out of the box. Simply subscribing to the patch stream of a tree is another way to sync diffs with for example back-end servers or other clients (example).
Since MST uses MobX behind the scenes, it integrates seamlessly with mobx and mobx-react. But even cooler; because it supports snapshots, middleware and replayable actions out of the box. It is even possible to replace a Redux store and reducer with a MobX state tree. This makes it even possible to connect the Redux devtools to MST. See the Redux / MST TodoMVC example.
Finally, MST has built-in support for references, identifiers, dependency injection, change recording and circular type definitions (even across files). Even fancier; it analyses liveleness of objects, failing early when you try to access accidentally cached information! (More on that later)
Despite all that, you will see that the API is pretty straight forward!
Another way to look at mobx-state-tree is to consider it, as argued by Daniel Earwicker, to be "React, but for data". Like React, MST consists of composable components, called models, which capture a small piece of state. They are instantiated from props (snapshots) and after that manage and protect their own internal state (using actions). Moreover, when applying snapshots, tree nodes are reconciled as much as possible. There is even a context-like mechanism, called environments, to pass information to deep descendants.
An introduction to the philosophy can be watched here. Slides. Or, as markdown to read it quickly.
TODO: react europe talk
Examples
TODO: move https://github.com/mweststrate/react-mobx-shop/tree/mobx-state-tree to this repo
Concepts
- The state is represented as a tree of models.
- models are created using factories.
- A factory basically takes a snapshot and a clone of a base model and copies the two into a fresh model instance.
- A snapshot is the immutable representation of the state of a model. In other words, a one-time copy of the internal state of a model at a certain point in time.
- snapshots use structural sharing. So a snapshot of a node in the tree is composed of the snapshots of it's children, where unmodified snapshots are always shared
mobx-state-tree
supports JSON patches, replayable actions, listeners for patches, actions and snapshots. References, maps, arrays. Just read on :)
Models
Models are at the heart of mobx-state-tree
. They simply store your data.
- Models are self-contained.
- Models have fields. Either primitive or complex objects like maps, arrays or other models. In short, these are MobX observables. Fields can only be modified by actions.
- Models have derived fields. Based on the
mobx
concept ofcomputed
values. - Models have actions. Only actions are allowed to change fields. Fields cannot be changed directly. This ensures replayability of the application state.
- Models can contain other models. However, models are not allowed to form a graph (using direct references) but must always have a tree shape. This enables many feature like standardized serialization and cloning.
- Models can be snapshotted at any time
- Models can be created using factories, that take copy a base model and combine it with a (partial) snapshot
TODO: properties & operations
Example:
import { types } from "mobx-state-tree"
import uuid from "uuid"
const Box = types.model("Box",{
// props
id: types.identifier(),
name: "",
x: 0,
y: 0,
// computed prop / views
get width() {
return this.name.length * 15
}
}, {
// actions
move(dx, dy) {
this.x += dx
this.y += dy
}
})
const BoxStore = types.model("BoxStore",{
boxes: types.map(Box),
selection: types.reference("boxes/name")
}, {
addBox(name, x, y) {
const box = Box.create({ id: uuid(), name, x, y })
this.boxes.put(box)
return box
}
})
const boxStore = BoxStore.create({
"boxes": {},
"selection": ""
});
const box = boxStore.addBox("test",100,100)
box.move(7, 3)
Useful methods:
types.model(exampleModel)
: creates a new factoryclone(model)
: constructs a deep clone of the given model instance
Snapshots
A snapshot is a representation of a model. Snapshots are immutable and use structural sharing (sinces model can contain models, snapshots can contain other snapshots). This means that any mutation of a model results in a new snapshot (using structural sharing) of the entire state tree. This enables compatibility with any library that is based on immutable state trees.
- Snapshots are immutable
- Snapshots can be transported
- Snapshots can be used to update / restore models to a certain state
- Snapshots use structural sharing
- It is posible to subscribe to models and be notified of each new snapshot
- Snapshots are automatically converted to models when needed. So assignments like
boxStore.boxes.set("test", Box({ name: "test" }))
andboxStore.boxes.set("test", { name: "test" })
are both valid.
Useful methods:
getSnapshot(model)
: returns a snapshot representing the current state of the modelonSnapshot(model, callback)
: creates a listener that fires whenever a new snapshot is available (but only one per MobX transaction).applySnapshot(model, snapshot)
: updates the state of the model and all its descendants to the state represented by the snapshot
Actions
Actions modify models. Actions are replayable and are therefore constrained in several ways:
- Actions can be invoked directly as method on a model
- All action arguments must be serializable. Some arguments can be serialized automatically, such as relative paths to other nodes
- Actions are serializable and replayable
- It is possible to subscribe to the stream of actions that is invoked on a model
- Actions can only modify models that belong to the tree on which they are invoked
- Actions are automatically bound the their instance, so it is save to pass actions around first class without binding or wrapping in arrow functions.
A serialized action call looks like:
{
name: "setAge"
path: "/user",
args: [17]
}
Useful methods:
- Use
name: function(/* args */) { /* body */ }
(ES5) orname (/* args */) { /* body */ }
(ES6) to construct actions onAction(model, middleware)
listens to any action that is invoked on the model or any of it's descendants. SeeonAction
for more details.applyAction(model, action)
invokes an action on the model according to the given action description
It is not necessary to express all logic around models as actions. For example it is not possible to define constructors on models. Rather, it is recommended to create stateless utility methods that operate on your models. It is recommended to keep models self-contained and to do orchestration around models in utilities around it.
(Un) protecting state tree
afterCreate() { unprotect(this) }
Views
TODO
Views versus actions
Exception: "Invariant failed: Side effects like changing state are not allowed at this point."
indicates that a view function tries to modifies a model. This is only allowed in actions.
Protecting the state tree
By default it is allowed to both directly modify a model or through an action.
However, in some cases you want to guarantee that the state tree is only modified through actions.
So that replaying action will reflect everything that can possible have happened to your objects, or that every mutation passes through your action middleware etc.
To disable modifying data in the tree without action, simple call protect(model)
. Protect protects the passed model an all it's children
const Todo = types.model({
done: false
}, {
toggle() {
this.done = !this.done
}
})
const todo = new Todo()
todo.done = true // OK
protect(todo)
todo.done = false // throws!
todo.toggle() // OK
Identifiers
Identifiers and references are two powerful abstraction that work well together.
- Each model can define zero or one
identifier()
properties - The identifier property of an object cannot be modified after initialization
- Identifiers should be unique within their parent collection (
array
ormap
) - Identifiers are used to reconcile items inside arrays and maps wherever possible when applying snapshots
- The
map.put()
method can be used to simplify adding objects to maps that have identifiers
Example:
const Todo = types.model({
id: types.identifier(),
title: "",
done: false
})
const todo1 = Todo.create() // not ok, identifier is required
const todo1 = Todo.create({ id: "1" }) // ok
applySnapshot(todo1, { id: "2", done: false}) // not ok; cannot modify the identifier of an object
const store = types.map(Todo)
store.put(todo1) // short-hand for store.set(todo1.id, todo)
References
References can be used to refer to link to an arbitrarily different object in the tree transparently. This makes it possible to use the tree as graph, while behind the scenes the graph is still properly serialized as tree
Example:
const Store = types.model({
selectedTodo: types.reference(Todo),
todos: types.array(Todo)
})
const store = Store({ todos: [ /* some todos */ ]})
store.selectedTodo = store.todos[0] // ok
store.selectedTodo === store.todos[0] // true
getSnapshot(store) // serializes properly as tree: { selectedTodo: { $ref: "../todos/0" }, todos: /* */ }
store.selectedTodo = Todo() // not ok; have to refer to something already in the same tree
By default references can point to any arbitrary object in the same tree (as long as it has the proper type).
References with predefined resolve paths
It is also possible to specifiy in which collection the reference should resolve by passing a second argument, the resolve path (this can be relative):
const Store = types.model({
selectedTodo: types.reference(Todo, "/todos/"),
todos: types.array(Todo)
})
If a resolve path is provided, reference
no longer stores a json pointer, but pinpoints the exact object that is being referred to by it's identifier. Assuming that Todo
specified an identifier()
property:
getSnapshot(store) // serializes tree: { selectedTodo: "17" /* the identifier of the todo */, todos: /* */ }
The advantage of this approach is that paths are less fragile, where default references serialize the path by for example using array indices, an identifier with a resolve path will find the object by using it's identifier.
Utility methods
- No restriction in arguments and return types
- Cannot modify data except though actions
Patches
Modifying a model does not only result in a new snapshot, but also in a stream of JSON-patches describing which modifications are made. Patches have the following signature:
export interface IJsonPatch {
op: "replace" | "add" | "remove"
path: string
value?: any
}
- Patches are constructed according to JSON-Patch, RFC 6902
- Patches are emitted immediately when a mutation is made, and don't respect transaction boundaries (like snapshots)
- Patch listeners can be used to achieve deep observing
- The
path
attribute of a patch considers the relative path of the event from the place where the event listener is attached - A single mutation can result in multiple patches, for example when splicing an array
Useful methods:
onPatch(model, listener)
attaches a patch listener to the provided model, which will be invoked whenever the model or any of it's descendants is mutatedapplyPatch(model, patch)
applies a patch to the provided model
Be careful with direct references to items in the tree
See #10
Factory composition
Tree semantics
TODO: document
LifeCycle hooks
Hook | Meaning |
---|---|
afterCreate |
Immediately after an instance is created and initial values are applied. Children will fire this event before parents |
afterAttach |
As soon as the direct parent is assigned (this node is attached to an other node) |
beforeDetach |
As soon as the node is removed from the direct parent, but only if the node is not destroyed. In other words, when detach(node) is used |
beforeDestroy |
Before the node is destroyed as a result of calling destroy or removing or replacing the node from the tree. Child destructors will fire before parents |
Single or multiple state
Using mobx and mobx-state-tree together
Integrations
Examples
Environments
FAQ
Should all state of my app be stored in mobx-state-tree
?
No, or, not necessarily. An application can use both state trees and vanilla MobX observables at the same time.
State trees are primarily designed to store your domain data, as this kind of state is often distributed and not very local.
For, for example, local component state, vanilla MobX observables might often be simpler to use.
No constructors?
Neh, replayability. Use utilities instead
No inheritance?
No use composition or unions instead.
Constraints
Some model constructions which are supported by mobx are not supported by mobx-state-tree
- Data graphs are not supported, only data trees
- This means that each object needs to uniquely contained
- Only containment relations are allowed. Associations need to be expressed with 'foreign keys'; strings identifying other objects. However there is a standard pattern enabling using real objects as references with a little boilerplate, see working with associations.
mobx-state-tree
does currently not support inheritance / subtyping. This could be changed by popular demand, but not supporting inheritance avoids the need to serialize type information or keeping a (global) type registery
Features
- Provides immutable, structurally shared snapshots which can be used as serialization or for time travelling. Snapshots consists entirely of plain objects.
- Provides JSON patch streams for easy remote synchronization or easy diffing.
- Each object is uniquely contained and has an explicit path like in a file system. This enables using relative references and is very useful for debugging.
- State trees are composable
- There can be many state trees in a single app.
Comparison with immutable state trees
So far this might look a lot like an immutable state tree as found for example in Redux apps, but there are a few differences:
- mobx-state-tree allow direct modification of any value in the tree, it is not needed to construct a new tree in your actions
- mobx-state-tree allows for fine grained and efficient observability on any point in the state tree
- mobx-state-tree generates json patches for any modification that is made
- (?) mobx-state-tree is a valid redux store, providing the same api (TODO)
TypeScript & MST
TypeScript support is best effort, as not all patterns can be expressed in TypeScript. But except for assigning snapshots to properties we got pretty close! As MST uses the latest fancy typescript features it is recommended to use TypeScript 2.3 or higher, with noImplicitThis
and strictNullChecks
enabled.
When using models, you write interface along with it's property types that will be used to perform type checks at runtime. What about compile time? You can use TypeScript interfaces indeed to perform those checks, but that would require writing again all the properties and their actions!
Good news? You don't need to write it twice! Using the typeof
operator of TypeScript over the .Type
property of a MST Type, will result in a valid TypeScript Type!
const Todo = types.model({
title: types.string
}, {
setTitle(v: string) {
this.title = v
}
})
type ITodo = typeof Todo.Type // => ITodo is now a valid TypeScript type with { title: string; setTitle: (v: string) => void }
Circular dependencies:
types.late(() => require("./OtherType"))