Nick Ribal
Front-end veteran, consultant and freelancer, who's armed with a decade of experience solving challenges on the Web platform for large Internet companies.
Opinionated, transactional, MobX powered state container combining the best features of the immutable and mutable world for an optimal DX
By Nick Ribal, August 2017
Concepts are the same, but the code won't work in modern mobx-state-tree>=0.9
Took me too long to realize all the cool kids are using MobX and raving about it. When I tried MobX, I learned it is indeed AWESOME!
MobX is a simple, low level non opinionated reactive state manager. I liked it, but had to write higher level abstractions on top of MobX managed entities
Michel Weststrate is a Smart Guy™ and I didn't want to miss a tool which is built on top of MobX and aims to solve the challenge of a higher level abstraction well
Loving front-end mandates learning a new way of doing everything every other week, doesn't it? ;)
MST basic concepts
Model declaration, types, type composition, computed properties, view functions
Instantiation, lifecycle hooks, volatile state and DI
Instantiation: from POJOs to a tree of models
Actions, listeners and middlewares
Snapshots & Patches
References & Identifiers
A tree of model instances, whose properties are typed and enforced at design & runtime
Actions change model state by creating a new snapshot via patching current state
This state is (de)serializable, reactive and normalized. This is the important stuff you persist
import { types } from 'mobx-state-tree'
const todoShape = {
title: types.string,
isFinished: false,
}
const todoActions = {
toggle(){
this.isFinished = !this.isFinished
}
}
export const TodoModel = types.model(
'TodoModel',
todoShape,
todoActions
)
Types declare shape of instance properties
Actions which modify instances, which are automatically bound
name + shape + actions = Class
const pretty = TodoModel.create({ title: 'seeking ❤' })
// this works, since 'isFinished' has a default value:
pretty.toJSON()
// outputs:
{title: "seeking ❤", isFinished: false}
// instance.toJSON() returns a POJO, not JSON
// It is just an alias of getSnapshot(instance)
Runtime type checking FTW!
const veryPretty = TodoModel.create({
title: 'seeking ❤ too',
isFinished: 'НЕТ'
})
Uncaught Error: [mobx-state-tree] Error while converting
`{"title":"seeking ❤ too","isFinished":"НЕТ"}` to
`TodoModel`: at path "/isFinished" value `"НЕТ"` is not
assignable to type: `boolean`.
const { // import { types } from 'mobx-state-tree'
model, array, optional, string, union, literal
} = types
const TodosModel = model('TodosModel', {
todos: array(TodoModel), // models are types too
priority: union(
...['HIGH', 'MEDIUM', 'LOW'].map(literal)
),
category: optional(string, 'Stuff to do'),
get completedTodos(){ // computed reactive property
return this.todos.filter(t => t.isFinished)
},
getTodosByTitle(title){ // view function
return this.todos.filter(t => t.title === title)
},
})
import { types, getEnv } from 'mobx-state-tree'
// PSEUDOCODE: not to be taken literally
const todosModelShape = {
todos: types.array(TodoModel),
get isLoading(){ // derived property isn't part of state
return this.pendingRequest.state === 'pending'
}
}
// State is volatile since it isn't part of the data
const todosModelVolatileState = { pendingRequest: null, }
// Declare actions which are MST lifecycle hooks
const todosModelActions = {
afterCreate(){ // getEnv(instance) returns DI object
this.pendingRequest = this.getEnv(this).fetch('//todos')
},
beforeDestroy(){
// abort the request, no longer interested
this.pendingRequest.abort()
}
}
// Declare our model
const TodosModel= types.model('TodosModel',
todosModelShape, todosModelVolatileState, todosModelActions
)
// Instantiate with data and DI
const myTodos = TodosModel.create(
{ todos: [] },
{ fetch: window.fetch } // real deps or mock/spies in tests
)
Hooks | Called when |
---|---|
preProcessSnapshot postProcessSnapshot |
Before/after instantiation and on/after snapshot application. Must be a pure function, returning a snapshot. Not called for individual property updates. |
afterCreate beforeDestroy |
Immediately after instantiation / before deletion. Children fire before parents. |
afterAttach beforeDetach |
After attaching / before detaching node to direct parent. |
const TodoModel = types.model(
{ title: types.string },
{ setTitle(t){ this.title = t } }
)
const TodosModel = types.model(
'TodosModel', { todos: types.array(TodoModel) }
)
// snapshot of nested data --> nested instances
const todos = TodosModel.create({
todos: [{ title: 'hello world' }]
})
// Run an action on a TodoModel in TodosModel
todos[0].setTitle('great success')
Nodes (models) can only be modified by one of their actions (by default), or by actions higher up in the tree
First class support* for async actions via ES2015 generators
Actions are replayable and are therefore constrained in several ways:
Listeners can subscribe to actions
Middlewares can intercept actions and do anything
// Our snapshot is just a POJO
const todoData = { title: 'test' }
// Explicit instantiation
todos.push(TodoModel.create(todoData))
// Implicit instantiation
todos.push(todoData)
// Applying a snapshot to model by mixing both
applySnapshot(todos, [...todos, todoData, ])
In addition to a snapshot, changing a model creates
(a stream of) JSON-patch(es), describing modifications
interface IJsonPatch {
op: 'replace' | 'add' | 'remove'
path: string
value?: any
}
Patch listeners can be used to deeply observe trees: patch.path is relative to listener attachment
A single change can create multiple patches, for example when splicing an array
applyPatch(model, patch) applies a patch (or array of patches) to model
revertPatch(model, patch) applies an inverse of patch (or array of patches) to model
Patches and their inverses can be applied to models, enabling patterns like undo / redo
const TodoModel = types.model({
id: types.identifier(),
title: types.string
})
const TodosModel = types.model({
todos: types.array(TodoModel),
selectedTodo: types.reference(TodoModle)
})
const todos = TodosModel.create({
todos: [{
id: '47',
title: 'A normalized snapshot'
}],
selectedTodo: '47'
})
// Access `selectedTodo` in a denormalized manner. Since it is a
// reference, MST returns the actual TodoModel node via matching
// identifier
console.log(todos.selectedTodo.title) // prints 'A normalized snapshot'
Identifiers are for reconciliation and reference resolution
// If all Car instances have an id of 'Car_xyz', use MST's
// rich types to enforce it:
const { model, identifier, refinement, string } = types
const CarModel = model('CarModel', {
id: identifier(
refinement(string, id => id.startsWith('Car_'))
)
})
By Nick Ribal
Redux vs MobX is predictability vs simplicity, transactions vs automatic state derivations, immutability vs mutability, explicit vs automagical. Enter mobx-state-tree: an opinionated bridge across these opposing principles which offers the best of both worlds - in a single end-to-end state management solution.
Front-end veteran, consultant and freelancer, who's armed with a decade of experience solving challenges on the Web platform for large Internet companies.