Security News
The Risks of Misguided Research in Supply Chain Security
Snyk's use of malicious npm packages for research raises ethical concerns, highlighting risks in public deployment, data exfiltration, and unauthorized testing.
mobx-state-tree
Advanced tools
Opinionated, transactional, MobX powered state container combining the best features of the immutable and mutable world for an optimal DX
npm install mobx-state-tree --save
yarn add mobx-state-tree
window.mobxStateTree
)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.
Simply put, 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. In other words; each tree has a shape (type information) and state (data). From this living tree, immutable, structurally shared, snapshots are generated automatically.
import { types, onSnapshot } from "mobx-state-tree"
const Todo = types.model("Todo", {
title: types.string,
done: false
}, {
toggle() {
this.done = !this.done
}
})
const Store = types.model("Store", {
todos: types.array(Todo)
})
// create an instance from a snapshot
const store = Store.create({ todos: [{
title: "Get coffee"
}]})
// listen to new snapshots
onSnapshot(store, (snapshot) => {
console.dir(snapshot)
})
// invoke action that modifies the tree
store.todos[0].toggle()
// prints: `{ todos: [{ title: "Get coffee", done: true }]}`
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!)
[mobx-state-tree] Value '{\"todos\":[{\"turtle\":\"Get tea\"}]}' is not assignable to type: Store, expected an instance of Store or a snapshot like '{ todos: { title: string; done: boolean }[] }' instead.
Runtime type error
Designtime type error
Because state trees are living, mutable models actions are straight-forward to write; just modify local instance properties where appropiate. See toggleTodo()
above or the examples below. It is not needed to produce a new state tree yourself, MST's snapshot functionality will derive one for you automatically.
Although mutable sounds scare to some, 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).
(screenshot of patches being emitted)
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.
(screenshot)
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)
A pretty unique feature of MST is that it offers livelyness guarantees; it will throw when reading or writing from objects that are for whatever reason no longer part of a state tree. This protects you against accidental stale reads of objects still referred by, for example, a closure.
const oldTodo = store.todos[0] store.removeTodo(0)
function logTodo(todo) {
setTimeout(
() => console.log(todo.title),
1000
)
)
logTodo(store.todos[0])
store.removeTodo(0)
// throws exception in one second for using an stale object!
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
TODO: move https://github.com/mweststrate/react-mobx-shop/tree/mobx-state-tree to this repo
mobx-state-tree
supports JSON patches, replayable actions, listeners for patches, actions and snapshots. References, maps, arrays. Just read on :)Models are at the heart of mobx-state-tree
. They simply store your data.
mobx
concept of computed
values.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 instanceA 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.
boxStore.boxes.set("test", Box({ name: "test" }))
and boxStore.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 snapshotActions modify models. Actions are replayable and are therefore constrained in several ways:
A serialized action call looks like:
{
name: "setAge"
path: "/user",
args: [17]
}
Useful methods:
name: function(/* args */) { /* body */ }
(ES5) or name (/* args */) { /* body */ }
(ES6) to construct actionsonAction(model, middleware)
listens to any action that is invoked on the model or any of it's descendants. See onAction
for more details.applyAction(model, action)
invokes an action on the model according to the given action descriptionIt 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.
afterCreate() { unprotect(this) }
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.
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 and references are two powerful abstraction that work well together.
identifier()
propertiesarray
or map
)map.put()
method can be used to simplify adding objects to maps that have identifiersExample:
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 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).
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.
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
}
path
attribute of a patch considers the relative path of the event from the place where the event listener is attachedUseful 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 modelSee #10
TODO: document
These are the types available in MST. All types can be found in the types
namespace, e.g. types.string
. See Api Docs for examples.
types.model(properties, actions)
Defines a "class like" type, with properties and actions to operate on the object.types.array(type)
Declares an array of the specified typetypes.map(type)
Declares an map of the specified typetypes.string
types.number
types.boolean
types.Date
types.union(dispatcher?, types...)
create a union of multiple types. If the correct type cannot be inferred unambigously from a snapshot, provide a dispatcher function.types.optional(type, defaultValue)
marks an value as being optional (in e.g. a model). If a value is not provided the defaultValue
will be used instead. If defaultValue
is a function, it will be evaluated. This can be used to generate for example id's or timestamps upon creation.types.literal(value)
can be used to create a literal type, a type which only possible value is specifically that value, very powerful in combination with union
s. E.g. temperature: types.union(types.literal("hot"), types.literal("cold"))
.types.refinement(baseType, (snapshot) => boolean)
creates a type that is more specific then the base type, e.g. types.refinement(types.string, value => value.length > 5)
to create a type of strings that can only be longer then 5.types.maybe(type)
makes a type optional and nullable, shorthand for types.optional(types.union(type, types.literal(null)), null)
.types.late(() => type)
can be used to create recursive or circular types, or types that are spread over files in such a way that circular dependencies between files would be an issue otherwise.types.frozen
Accepts any kind of serializable value (both primitive and complex), but assumes that the value itself is immutable.Property types can only be used as direct member of a types.model
type and not further composed (for now).
types.identifier(subType?)
Only one such member can exist in a types.model
and should uniquely identify the object. See identifiers for more details. subType
should be either types.string
or types.number
, defaulting to the first if not specified.types.reference(targetType, basePath?)
creates a property that is a reference to another item of the given targetType
somewhere in the same tree. See references for more details.types.model
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 |
signature | |
---|---|
addDisposer(node, () => void) | Function to be invoked whenever the target node is to be destroyed |
addMiddleware(node, middleware: (actionDescription, next) => any) | Attaches middleware to a node. See actions. Returns disposer. |
applyAction(node, actionDescription) | Replays an action on the targeted node |
applyPatch(node, jsonPatch) | Applies a JSON patch to a node in the tree |
applySnapshot(node, snapshot) | Updates a node with the given snapshot |
asReduxStore(node) | Wraps a node in a Redux store compatible api |
`clone(node, keepEnvironment?: true | false |
connectReduxDevtools(removeDevModule, node) | Connects a node to the redux development tools example |
destroy(node) | Kills a node, making it unusable. Removes it from any parent in the process |
detach(node) | Removes a node from it's current parent, and let's it live on as stand alone tree |
getChildType(node, property?) | Returns the declared type of the given property of a node. For arrays and maps property can be omitted as they all have the same type |
getEnv(node) | Returns the environment of the given node, see environments |
getParent(node, depth=1) | Returns the intermediate parent of the given node, or a higher one if depth > 1 |
getPath(node) | Returns the path of a certain node in the tree |
getPathParts(node) | Returns the path of a certain node in the tree, unescaped as separate parts |
getRelativePath(base, target) | Returns the short path which one could use to walk from node base to node target , assuming they are in the same tree. Up is represented as ../ |
getRoot(node) | Returns the root element of the tree containing node |
getSnapshot(node) | Returns the snapshot of provided node. See snapshots |
getType(node) | Returns the type of the given node |
hasParent(node, depth=1) | Returns true if the node has a parent at the given depth |
isAlive(node) | Returns true if the node hasn't died yet |
isMST(value) | Returns true if the value is a node of a mobx-state-tree |
isProtected(value) | Returns true if the given node is protected, see actions |
isRoot(node) | Returns true if the has no parents |
joinJsonPath(parts) | Joins and escapes the given path parts into a json path |
onAction(node, (actionDescription) => void | A built-in middleware that calls the provided callback with an action description upon each invocation. Returns disposer |
onPatch(node, (patch) => void) | Attach a JSONPatch listener, that is invoked for each change in the tree. Returns disposer |
onSnapshot | Attach a snapshot listener, that is invoked for each change in the tree. Returns disposer |
protect | Protects an unprotected tree against modifications from outside actions |
recordActions(node) | Creates a recorder that listens to all actions in the node. Call .stop() on the recorder to stop this, and .replay(target) to replay the recorded actions on another tree |
recordPatches | Creates a recorder that listens to all patches emitted by the node. Call .stop() on the recorder to stop this, and .replay(target) to replay the recorded patches on another tree |
resolve(node, path) | Resolves a path (json path) relatively to the given node |
splitJsonPath(path) | Splits and unescapes the given json path into path parts |
tryResolve(node, path) | Like resolve , but just returns null if resolving fails at any point in the path |
unprotect(node) | Unprotects a node, making it possible to directly modify any value in the subtree, without actions |
walk(startNode, (node) => void) | Performs a depth-first walk through a tree |
A disposer is a function that cancels the effect it was created for.
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.
Some model constructions which are supported by mobx are not supported by mobx-state-tree
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 registerySo far this might look a lot like an immutable state tree as found for example in Redux apps, but there are a few differences:
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 }
types.late(() => require("./OtherType"))
Thanks to @gcanti who inspired lot of the Type and validation API!
FAQs
Opinionated, transactional, MobX powered state container
We found that mobx-state-tree demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 8 open source maintainers collaborating on the project.
Did you know?
Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.
Security News
Snyk's use of malicious npm packages for research raises ethical concerns, highlighting risks in public deployment, data exfiltration, and unauthorized testing.
Research
Security News
Socket researchers found several malicious npm packages typosquatting Chalk and Chokidar, targeting Node.js developers with kill switches and data theft.
Security News
pnpm 10 blocks lifecycle scripts by default to improve security, addressing supply chain attack risks but sparking debate over compatibility and workflow changes.