Intro to

mobx-state-tree

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

OUTDATED

Concepts are the same, but the code won't work in modern mobx-state-tree>=0.9

Nick Ribal

image/svg+xml

Front-end consultant, freelancer and a family man

    @elektronik

Working for CodeValue

Today: just a bunch of hippies

Soon: that + digital nomads, ZOMG!!!1

Why mobx-state-tree?

  • 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? ;)

Topics we'll cover

  • 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

MST basic concepts

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

Model declaration

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

Example: Russian models

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`.

Type composition, computed/derived properties, view functions

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)
  },
})

MST types are very rich and can describe virtually anything

Model instantiation, lifecycle hooks, volatile state and dependency injection

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
)

Lifecycle hooks

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.

These are just types.model built-in actions

Growing a tree: a snapshot of nested models' data

becomes a tree of nested model node instances

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')

Actions

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:

  • Trying to modify a node outside of an action throws an exception
  • All action arguments should be serializable
  • Can only modify models that belong to the (sub)tree on which they are invoked
  • Actions are bound to their instance. Pass them around without worrying about this

Actions: listeners and middlewares

Listeners can subscribe to actions

  • Before they are invoked
  • Modify arguments, return types etc
  • Receive raw arguments
  • onAction is just a built-in middleware, which implements a pubsub
  • Notified of actions, can't intercept or modify them
  • Receive the action arguments in serializable format

Middlewares can intercept actions and do anything

Snapshot, getSnapshot(model)

  • An immutable serialization, in POJOs, of a tree at a specific point in time
  • It is pure data: free of types, actions, methods and metadata
  • Perfectly suitable for serialization and transportation
  • Getting one is cheap: MST always has a snapshot of each node

Setting model state from snapshots

  • Can be used to instantiate, update/set/restore models to a particular state
  • Automatically converted to models when needed
// 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, ])

All 3 are equivalent

Patches

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 signature, in TypeScript

  • Patches adhere to JSON-Patch, RFC 6902
  • 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

on/apply/revertPatch

  • onPatch(model, listener) adds a patch listener to model, which is invoked when it or any of its descendants are mutated
  • 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

References and identifiers

  • A first-class concept in MST
  • Declarative references keep data normalized
  • You interact with denormalized data
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

  • Optional: models can have none, one or several
  • Immutable: can't be defined or changed after instantiation
  • Each model combination of identifier(s) + types must be unique within entire tree
  • Used to reconcile items inside arrays and maps (objects) when applying snapshots
  • You can still use them for validation, if you need to:
// 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_'))
  )
})

Not finished, not perfect

  • Async actions don't work when transpiled, yet - this is being worked on
  • Docs, guides and examples need to be improved and clarified
  • Model API is being reworked to a better, composable one, 'this' free + a migration codemod for an easy upgrade

Sources and references

  • Project README, getting started guide, API
  • The Quest For Immer Mutable State Management by Michel Weststrate

  • Next Generation State Management by Michel Weststrate

Thank you and

stay curious :)

[OUTDATED] Intro to mobx-state-tree

By Nick Ribal

[OUTDATED] Intro to mobx-state-tree

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.

  • 5,311