Logomobx-keystone
API Ref
IntroductionComparison with mobx-state-treeClass ModelsOverviewYour first class modelClass model rulesCreating a class model instanceAutomatic class model actions for property settersLife-cycle event hooksRuntime dataAccessing the type and ID of a class modelGetting the Typescript types for model data and model creation dataFlows (async actions)Factory patternInheritanceUsage without decoratorsFunctional ModelsTree-Like StructureRoot StoresSnapshotsPatchesMaps, Sets, Dates
Action Middlewares
ContextsReferencesFrozen DataRuntime Type CheckingProperty TransformsDraftsSandboxesRedux Compatibility
Examples

Class Models

Overview

mobx-keystone supports the following kinds of data:

  • Class models, which are like objects but enhanced with local behaviors (actions/views) and life-cycle events (hooks).
  • Functional models, which only define behaviors (actions/views) over plain data and do not have life-cycle events (hooks).
  • Objects, which serve as basic storages of data (kind of like class models, except without actions and life-cycle events), as well as key-value maps of other data.
  • Arrays.
  • Primitive values (string, boolean, number, null, undefined).

In this section we will focus on class models, since the other types can be used as children in the usual way.

Your first class model

A class model for a todo can be defined as follows:

// the model decorator marks this class as a model, an object with actions, etc.
// the string identifies this model type and must be unique across your whole application
@model("myCoolApp/Todo")
export class Todo extends Model({
// here we define the type of the model data, which is observable and snapshotable
// and also part of the required initialization data of the model
// in this case we don't use runtime type checking
text: prop<string>(), // a required string
done: prop(false), // an optional boolean that will default to false when the input is null or undefined
// if you want to make a property truly optional then use x: prop<TYPE | undefined>()
// if we required runtime type checking we could do this
// text: tProp(types.string),
// done: tProp(types.boolean, false),
// if you want to make a property truly optional then use x: tProp(types.maybe(TYPE))
}) {
// the modelAction decorator marks the function as a model action, giving it access
// to modify any model data and other superpowers such as action
// middlewares, replication, etc.
@modelAction
setDone(done: boolean) {
this.done = done
}
@modelAction
setText(text: string) {
this.text = text
}
@computed
get asString() {
return `${!this.done ? "TODO" : "DONE"} ${this.text}`
}
}

Note that there are several ways to define properties.

Without runtime type checking:

  • prop<T>(options?: ModelOptions) - A property of a given type, with no default set if it is null or undefined in the initial data.
  • prop<T>(defaultValue: T, options?: ModelOptions) - A property of a given type, with a default set if it is null or undefined in the initial data. Use this only for default primitives.
  • prop<T>(defaultFn: () => T, options?: ModelOptions) - A property of a given type, with a default value generator if it is null or undefined in the initial data. Usually used for default objects / arrays / models.

With runtime type checking (check the relevant section for more info):

  • tProp(type, options?: ModelOptions) - A property of a given runtime checked type, with no default set if it is null or undefined in the initial data.
  • tProp<T>(type, defaultValue: T, options?: ModelOptions) - A property of a given runtime checked type, with a default set if it is null or undefined in the initial data. Use this only for default primitives.
  • tProp<T>(type, defaultFn: () => T, options?: ModelOptions) - A property of a given runtime checked type, with a default value generator if it is null or undefined in the initial data. Usually used for default objects / arrays / models.

Class model rules

The rules that need to be followed to declare a class model are:

  • Class models have to be decorated with @model and require a unique across-application ID for the class type.
  • They have to extend Model, which in Typescript requires the type of the data that will become observable / snapshotable / patchable.
  • This data (that is observable and part of the snapshot) can be accessed / changed through this as well as this.$.
  • Model actions need to be used in order to be able to change such data.
  • Never declare your own constructor, there are life-cycle events for that (more on that later).

Of course primitives are not the only kinds of data that a class model can hold. Arrays, plain objects and other objects can be used as well.

Creating a class model instance

An instance of the todo model above can be created like this:

const myTodo1 = new Todo({ done: true, text: "buy some milk" })
// note how `done` can be skipped since it was declared with a default value
const myTodo2 = new Todo({ text: "buy some coffee" })

Automatic class model actions for property setters

Since most of the times, the only action we need for a property is a setter we can use the model option setterAction to reduce boilerplace and allow setting properties as if they were wrapped in a modelAction. For example, the model above could be written as:

@model("myCoolApp/Todo")
export class Todo extends Model({
text: prop<string>({ setterAction: true }),
done: prop(false, { setterAction: true }),
}) {}
const myTodo = new Todo({ text: "buy some coffee" })
// this is now allowed and properly wrapped in two respective actions
myTodo.text = "buy some milk"
myTodo.done = true

Note however that this does not apply when setting the properties under $, sub-objects or array methods. For example, these would still require a modelAction:

// all these will throw
myTodo.$.text = "buy some milk"
myTodo.$.done = true
// given someObject uses setterAction
myTodo.someObject.foo = ...
// given someArray uses setterAction
myTodo.someArray.push(...)

If for some reason you still require to change these without using a modelAction consider using fnObject.set, fnObject.delete, fnObject.call, fnArray.push, etc. if needed.

Life-cycle event hooks

Class models can optionally include an implementation for each of the life-cycle hooks:

  • onInit(), which serves as a replacement for the constructor and will fire as soon as the model is created. On most occasions, it is better to use the next hook.
  • onAttachedToRootStore(rootStore), which fires once a class model becomes part of a root store tree and which can optionally return a disposer which will run once the model detaches from such root store tree. It will be explained in detail in the root stores section.

Runtime data

Runtime data (data that doesn't need to be snapshotable, or that needs to be tracked in any way) can be declared as a usual property. Nothing special is needed.

@model("myApp/SomeModel")
class SomeModel extends Model({...}) {
// non-observable runtime data
x = 10;
setX(x: number) {
this.x = x
}
// or observable in the usual MobX way
@observable
y = 20;
@action
setY(y: number) {
this.y = y
}
}

Accessing the type and ID of a class model

It is interesting to observe that class models include the properties named $modelType and $modelId:

myTodo1.$modelType // "myCoolApp/Todo"
myTodo1.$modelId // "Td244..."

These properties will end up in the snapshot representation of the model and it will serve to be able to properly reconstruct the proper model class from the snapshot.

Note that while $modelId is usually auto-generated, you can override this behaviour when creating the object by specifying it as a property:

const myTodo = new Todo({
$modelId: "my custom id",
})

as well as writing to it inside a model action:

// inside some model action
this.$modelId = "my new custom id"

Just make sure that ID is unique for every model object (no matter its type).

The default model ID generator function is tuned up to be fast and works like this:

const baseLocalId = base64WithoutDashes(uuid.v4())
let localId = 0
function generateModelId() {
return localId.toString(36) + "-" + baseLocalId
}

This has the implications however that every model ID generated by the same client / server session will have a different first part of the ID, yet share the same last part of such ID.

That being said, it is possible to use a custom function to generate model IDs using setGlobalConfig:

setGlobalConfig({
modelIdGenerator: myModelIdGeneratorFunction,
})

If for some reason you do not want to have/require these extra properties in your snapshots please see functional models instead.

Getting the Typescript types for model data and model creation data

  • ModelPropsData<Model> is the type of the model props without transformations (as accessible via model.$).
  • ModelInstanceData<Model> is the type of the model props with transformation (as accessible via this).
  • ModelPropsCreationData<Model> is the the type of the creation model props without transformations (like SnapshotIn<Model> excluding $modelId and $modelType).
  • ModelInstanceCreationData<Model> is the type of the first parameter passed to new Model(...).

For example, given:

@model("myCoolApp/Todo")
export class Todo extends Model({
text: prop<string>(), // a required string
done: prop(false), // an optional boolean that will default to false
}) {}

ModelInstanceCreationData<Todo> would be:

{
text: string; // required when passing it to new Todo({...})
done?: boolean | null; // optional when passing it to new Todo({...})
}

and ModelInstanceData<Todo> would be:

{
text: string // since it will always be present when accessing todo.text
done: boolean // since it will always be present when accessing todo.done
}

Flows (async actions)

While @modelAction defines sync model actions, async model actions are possible as well with the use of @modelFlow:

interface Book {
title: string
price: number
}
@model("myApp/BookStore")
class BookStore extends Model({
books: prop<Book[]>(() => []),
}) {
// typescript version
@modelFlow
// note: `_async` is a function that has to be imported, we have to use `this: THISCLASS`
fetchMyBooksAsync = _async(function*(this: BookStore, token: string) {
// we use `yield* _await(X)` where we would use `await X`
// note: it is `yield*`, NOT just `yield`; `_await` is a function that has to be imported
const myBooks = yield* _await(myBackendClient.getBooks(token))
this.books = myBooks
});
// javascript version
@modelFlow
// we use function* (a function generator) where we would use `async`
*fetchMyBooksAsync(token) {
// we use `yield* _await(X)` where we would use `await X`
// note: it is `yield*`, NOT just `yield`; `_await` is a function that has to be imported
const myBooks = yield* _await(myBackendClient.getBooks(token))
this.books = myBooks
}
}
// in either case it can be used like this
const myBookStore = new BookStore({})
await myBookStore.fetchMyBooksAsync("someToken")

Factory pattern

It is possible to use a factory pattern with class models. For example:

function createModelClass<TX, TY>(modelName: string, initialX: TX, initialY: TY) {
@model(`myApp/${modelName}`)
class MyModel extends Model({
x: prop<TX>(() => initialX),
y: prop<TY>(() => initialY),
}) {
@modelAction
setXY(x: TX, y: TY) {
this.x = x
this.y = y
}
}
return MyModel
}
const NumberMyModel = createModelClass("NumberMyModel", 10, 20)
type NumberMyModel = InstanceType<typeof NumberMyModel>
const numberMyModelInstance = new NumberMyModel({}) // this will be of type NumberMyModel
numberMyModelInstance.setXY(50, 60)
const StringMyModel = createModelClass("StringMyModel", "10", "20")
type StringMyModel = InstanceType<typeof StringMyModel>
const stringMyModelInstance = new StringMyModel({}) // this will be of type StringMyModel
stringMyModelInstance.setXY("50", "60")

Note that the above will only work when not generating declaration maps. If you need to generate declarations (for example for a library) then it is a bit more tedious, but still possible:

export function createModelClass<TX, TY>(modelName: string, initialX: TX, initialY: TY) {
const MyModelProps = Model({
x: prop<TX>(() => initialX),
y: prop<TY>(() => initialY),
})
@model(`myApp/${modelName}`)
class MyModel extends MyModelProps {
@modelAction
setXY(x: TX, y: TY) {
this.x = x
this.y = y
}
}
return MyModel as ModelClassDeclaration<
typeof MyModelProps,
{
setXY(x: TX, y: TY): void
}
>
}

Inheritance

Model inheritance is possible with a few gotchas.

The first thing to bear in mind is that class models that extend from other class models must use ExtendedModel rather than the plain Model. For example:

@model("MyApp/Point")
class Point extends Model({
x: prop<number>(),
y: prop<number>(),
}) {
get sum() {
return this.x + this.y
}
}
// note how ExtendedModel is used
@model("MyApp/Point3d")
class Point3d extends ExtendedModel(BasePoint, {
z: prop<number>(),
}) {
get sum() {
return super.sum + this.z
}
}

Also, remember that if your base model has onInit / onAttachedToRootStore and you redeclare them in your extended model you will need to call super.onInit(...) / super.onAttachedToRootStore(...) in the extended model.

If you want to extend a generic class, then you may want to use modelClass in order to specify the exact generic like this:

class X extends ExtendedModel(modelClass<SomeGenericClass<string>>(SomeGenericClass), { ... }) { ... }

If you don't it will still compile, but the generic will be assumed to have unknown for all its generic parameters.

Usage without decorators

Although this library was primarily intented to be used with decorators it is also possible to use it without them.

To do so you can use the decoratedModel method as shown below:

const Todo = decoratedModel(
// the string identifies this model type and must be unique across your whole application
// you may pass `undefined` if you don't want the model to be registered yet (e.g. for a base class)
"myCoolApp/Todo",
// note: although the name of class here is optional it is recommended when using Typescript to make
// the IDE show better typings and emit shorter declarations
class Todo extends Model({
text: prop<string>(), // a required string
done: prop(false), // an optional boolean that will default to false when the input is null or undefined
}) {
// note how here we don't decorate the method directly, but on the next parameter instead
// @modelAction
setDone(done: boolean) {
this.done = done
}
// @modelAction
setText(text: string) {
this.text = text
}
// @computed
get fullText() {
return `${this.done ? "DONE" : "TODO"} - ${this.text}`
}
},
// here we pass what we would use as decorators to the class methods/properties above
// if we want to use multiple chained decorators we can pass an array of them instead
// note that any kind of Typescript compatible decorator is supported, not only the built-in ones!
{
setDone: modelAction,
setText: modelAction,
fullText: computed,
}
)
// needed to be able to do SnapshotInOf<Todo>, etc
type Todo = InstanceType<typeof Todo>
const myTodo = new Todo({ done: false, text: "buy some milk" })