# mobx-keystone # intro [![npm](https://img.shields.io/npm/v/mobx-keystone.svg?style=for-the-badge&logo=npm&labelColor=333)](https://www.npmjs.com/package/mobx-keystone) ![license](https://img.shields.io/npm/l/mobx-keystone.svg?style=for-the-badge&labelColor=333) ![types](https://img.shields.io/npm/types/mobx-keystone.svg?style=for-the-badge&logo=typescript&labelColor=333)
[![CI](https://img.shields.io/github/actions/workflow/status/xaviergonz/mobx-keystone/main.yml?branch=master&label=CI&logo=github&style=for-the-badge&labelColor=333)](https://github.com/xaviergonz/mobx-keystone/actions/workflows/main.yml) [![codecov](https://img.shields.io/codecov/c/github/xaviergonz/mobx-keystone?token=6MLRFUBK8V&label=codecov&logo=codecov&style=for-the-badge&labelColor=333)](https://codecov.io/gh/xaviergonz/mobx-keystone) [![Netlify Status](https://img.shields.io/netlify/c5f60bcb-c1ff-4d04-ad14-1fc34ddbb429?label=netlify&logo=netlify&style=for-the-badge&labelColor=333)](https://app.netlify.com/sites/mobx-keystone/deploys) `mobx-keystone` 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_, all with a fully compatible TypeScript syntax. Simply put, it 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-keystone` is very opinionated about how data should be structured and updated. This makes it possible to solve many common problems out of the box. Central in `mobx-keystone` is the concept of a _living tree_. The tree consists of mutable, but strictly protected objects (models, arrays and plain objects). From this living tree, immutable, structurally shared snapshots are automatically generated. Another core design goal of `mobx-keystone` is to offer a great TypeScript syntax out of the box, be it for models (and other kinds of data such as plain objects and arrays) or for its generated snapshots. Need markdown exports for LLM workflows? See [llms.txt](pathname:///llms.txt), [llms-full.txt](pathname:///llms-full.txt), or the [MD docs intro](pathname:///md/intro.md). To see some code and get a glimpse of how it works check the [Todo List Example](./examples/todoList/todoList.mdx). Because state trees are living, mutable models, actions are straightforward to write; just modify local instance properties where appropriate. It is not necessary to produce a new state tree yourself, `mobx-keystone`'s snapshot functionality will derive one for you automatically. Although mutable sounds scary to some, fear not, actions have many interesting properties. By default, trees can only be modified by using an action that belongs to the same subtree. Furthermore, actions are replayable and can be used to distribute changes. Moreover, because 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. Since `mobx-keystone` uses MobX behind the scenes, it integrates seamlessly with [`mobx`](https://mobx.js.org) and [`mobx-react`](https://github.com/mobxjs/mobx-react). Even cooler, because it supports snapshots, action middlewares and replayable actions out of the box, it is possible to replace a Redux store and reducer with a MobX data model. This makes it possible to connect the Redux devtools to `mobx-keystone`. Like React, `mobx-keystone` consists of composable components, called _models_, which capture small pieces of state. They are instantiated from props and after that manage and protect their own internal state (using actions). Moreover, when applying snapshots, tree nodes are reconciled as much as possible. --- # installation This library requires a more or less modern JavaScript environment to work, namely one with support for: - MobX 6, 5, or 4 (with its gotchas) - Proxies - Symbols - WeakMap/WeakSet In other words, it should work on mostly anything except _it won't work in Internet Explorer_. If you are using TypeScript: - Version 5.0+ is recommended (standard decorators). - Legacy decorators are also supported (`experimentalDecorators: true`). ## Transpiler configuration This library uses JavaScript decorators and class properties. ### Standard decorators Use this mode if you are on TypeScript 5+ and want standard decorators. For Babel, ensure class static blocks are transformed (for example via `@babel/preset-env` targets, or by adding `@babel/plugin-transform-class-static-block`). ### Legacy decorators Use this mode if your project is on legacy decorators (`experimentalDecorators: true`). --- # mstComparison This library is very much like `mobx-state-tree` and takes lots of ideas from it, so the transition should be fairly simple. There are some trade-offs though, as shown in the following chart: | Feature | `mobx-keystone` | `mobx-state-tree` | | ----------------------------------------- | --------------------------------------------- | --------------------------- | | Tree-like structure | | | | Immutable snapshot generation | | | | Patch generation | | | | Action serialization / replaying | | | | Action middleware support | (1) | | | - Atomic/Transaction middleware | | | | - Undo manager middleware | | | | Flow action support | | | | References | | | | Frozen data | | | | TypeScript support | (2) | | | Simplified instance / snapshot type usage | | | | Simplified model life-cycle | | | | Runtime type validation | (3) | | | No metadata inside snapshots | (4) | | | Redux compatibility layer | | | 1. Includes an improved action tracking middleware that makes it easier to create middlewares for flow (async) actions. 2. Support for self-model references / cross-model references / no need for late types, no need for casting, etc. 3. Runtime type checking / type definitions are completely optional in `mobx-keystone`. 4. Only when using data models, although they lack life-cycle support. ## TypeScript improvements `mobx-state-tree` has some limitations when it comes to TypeScript typings, which `mobx-keystone` tries to overcome. ### If you know TypeScript you already know how to type models `mobx-keystone`, when not using runtime type checking, uses standard TypeScript type annotations to declare the data of models, therefore lowering the learning curve. However, if you need runtime type checking, `mobx-keystone` includes a completely optional type definition / runtime type checking system as well. ### Self-recursive and cross-referenced models Self-recursive or cross-referenced models are impossible (or at least very hard) to properly type in `mobx-state-tree`, but they become trivial with `mobx-keystone`. ```ts // self recursive model @model("myApp/TreeNode") class TreeNode extends Model({ children: prop(() => []) }) {} // cross-referenced models @model("myApp/A") class A extends Model({ b: prop() }) {} @model("myApp/B") class B extends Model({ a: prop() }) {} ``` ### Simpler instance / snapshot type usage Another area of improvement is the simplification of the usage of snapshot vs. instance types. In `mobx-state-tree` it is possible to assign snapshots to properties, as well as actual instances, but the actual type of properties are instances, which leads to confusing casts and constructs such as: ```ts // mobx-state-tree code const Todo = types .model({ done: false, text: types.string, }) .actions((self) => ({ setText(text: string) { self.text = text }, setDone(done: boolean) { self.done = done }, })) const RootStore = types .model({ selected: types.maybe(Todo), }) .actions((self) => ({ // note the usage of a union of the snapshot type and the instance type setSelected(todo: SnapshotIn | Instance) { // note the usage of cast to indicate that it is ok to use a snapshot when // the property actually expects an instance self.selected = cast(todo) }, })) ``` In `mobx-keystone` snapshots are usually only expected when dealing with `getSnapshot` and `fromSnapshot`, so it leads to a simpler usage: ```ts @model("myApp/Todo") class Todo extends Model({ done: prop(false).withSetter(), text: prop().withSetter(), }) {} @model("myApp/RootStore") class RootStore extends Model({ selected: prop(undefined).withSetter(), }) {} ``` ### Less confusion between this/self usages - use of standard computed decorators Usually in `mobx-state-tree` code from a previous "chunk" (actions, views) has to be accessed using `self`, while code in the same "chunk" has to be accessed using `this` to get proper typings: ```ts // mobx-state-tree code const Todo = types .model({ done: false, text: types.string, title: types.string, }) .views((self) => ({ get asStr() { // here we use `self` since the properties come from a previous chunk return `${self.text} is done? ${self.done}` }, get asStrWithTitle() { // here we use `this` for `asStr` since it comes from the current chunk return `${self.title} - ${this.asStr}` }, })) ``` In `mobx-keystone` `this` can always be used, plus the standard `computed` MobX decorator (including extra options): ```ts @model("myApp/Todo") class Todo extends Model({ done: prop(false), text: prop(), title: prop(), }) { @computed get asStr() { return `${this.text} is done? ${this.done}` } @computed get asStrWithTitle() { return `${this.title} - ${this.asStr}` } } ``` ## Simplified model life-cycle `mobx-state-tree` has a couple of life-cycle hooks (`afterCreate`, `afterAttach`, `beforeDetach`, `beforeCreate`) that might or might not trigger when you think they should due to the lazy initialization of nodes. For example, you might create a submodel with an `afterCreate` hook, but it might never be actually executed unless the node contents are accessed (due to lazy initialization). Maybe you might want to set up an effect (`reaction` or the like), but you only want that effect to work after it actually becomes part of your application state. Likewise, you might want to call `getRoot` to access the root model, but it might actually not give the value you expect until the model is attached to a parent which is eventually (or not) attached to the proper root. `mobx-keystone` solves this by only offering two life-cycle hooks: 1. `onInit` which is _always_ called once the model has been created (and since there's no lazy initialization they will always be) 1. `onAttachedToRootStore` (plus an optional disposer that gets executed when it is detached) which gets called once the model gets attached to the proper root node (a root store), thus ensuring that at that point `getRoot` will return the expected value and makes it a perfect place to set up effects (more info in the [class models](./classModels.mdx) section) --- # classModels ## 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). - Data models, which only define behaviors (actions/views) over "untainted" data and have a _very_ limited number of 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: ```ts // 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(), // 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()` // 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 method 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(options?: ModelOptions)` - A property of a given type, with no default set if it is `null` or `undefined` in the initial data. - `prop(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(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(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(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 model 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: ```ts 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 Most times, the only action we need for a property is a setter. We can use the prop modifier `withSetter()` (`withSetter("assign")` has been deprecated) to reduce boilerplate and generate property setters. For example, the model above could be written as: ```ts @model("myCoolApp/Todo") export class Todo extends Model({ text: prop().withSetter(), done: prop(false).withSetter(), }) {} const myTodo = new Todo({ text: "buy some coffee" }) // this is now allowed and properly wrapped in two respective actions myTodo.setText("buy some milk") myTodo.setDone(true) ``` If for some reason you still require to change these without using a `modelAction`, consider using `objectActions.set`, `objectActions.delete`, `objectActions.call`, `arrayActions.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](./rootStores.mdx) 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. ```ts @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 a property named `$modelType`: ```ts myTodo1.$modelType // "myCoolApp/Todo" ``` This property 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, therefore it is usually required. That being said, there are actually two ways to skip this requirement (having `$modelType` in input snapshots): - If a property is typed using `tProp` then the model(s) in that property won't need `$modelType`. - If a model is constructed using the `fromSnapshot` overload that takes a type as first parameter then that snapshot won't need `$modelType`. ## Setting an ID property Note that it is also possible to assign a property as the ID property using `idProp`. Setting a dedicated ID property has some advantages: - Improved reconciliation when applying snapshots. - Resolving the target of serialized actions will be less likely to hit the wrong node (thanks to ID checking). - Root references will be able to resolve the nodes without any extra configuration. ```ts @model("myApp/ModelWithCustomId") class ModelWithCustomId extends Model({ customId: idProp }) { ... } ``` In this case either `customId` (both in snapshots and instances) or `$modelId` (only in instances) can be used to read / write the ID property. Just make sure that ID is unique for every model object (no matter its type). Note that when using the `new` operator to create a new instance you can either not specify the property so it will be auto-generated, or you can specify it directly: ```ts const myTodo = new Todo({ myId: "my custom id", }) ``` as well as writing to it at a later time inside a model action: ```ts // inside some model action this.myId = "my new custom id" // or alternatively this.$modelId = "my new custom id" ``` If you wish to type the ID property even further (for example by using a string template) you may do it like this: ```ts idProp.typedAs<`custom-${string}`> ``` ## Customizing the ID generator function The default model ID generator function is tuned up to be fast and works like this: ```ts const baseLocalId = nanoid() 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`: ```ts setGlobalConfig({ modelIdGenerator: myModelIdGeneratorFunction, }) ``` ## Getting the TypeScript types for model data and model creation data - `ModelData` is the type of the model props without transformations (as accessible via `model.$`). - `ModelCreationData` is the type of the first parameter passed to `new Model(...)`. For example, given: ```ts @model("myCoolApp/Todo") export class Todo extends Model({ text: prop(), // a required string done: prop(false), // an optional boolean that will default to `false` }) {} ``` `ModelCreationData` would be: ```ts { text: string; // required when passing it to `new Todo({...})` done?: boolean | null; // optional when passing it to `new Todo({...})` } ``` and `ModelData` would be: ```ts { 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`: ```ts interface Book { title: string price: number } @model("myApp/BookStore") class BookStore extends Model({ books: prop(() => []), }) { // 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") ``` ## Value-type class models Sometimes it is useful to have models that act like a primitive. This is, models that would get automatically cloned when attached to a tree rather than relying on having a single instance of it in the whole tree. For example, say that you have a model for a RGB color: ```ts @model("myApp/Color") class Color extends Model( { r: prop(), g: prop(), b: prop(), }, { valueType: true, } ) {} ``` Usually, without `valueType: true`, whenever we wanted to use the same color in two separate paths of the tree we would need to clone it first, or else we would get an error about the node trying to have two parents at once. With `valueType` set this would be no longer the case, since the node would get cloned automatically if it already had a parent: ```ts class MyColors extends Model({ primary: prop(), secondary: prop(), }) { // ... } // without `valueType: true` this would throw an error // but with it `primary` is now a clone of `secondary` myColors.setPrimary(myColors.secondary) ``` Note that it is an actual clone, this is, changing the primary color won't change the secondary one. ## Factory pattern / Generics If you are _not_ relying on `tProp` to do runtime type checking it is possible to use this pattern to get generic classes: ```ts @model("myApp/GenericPoint") class GenericPoint extends Model(() => ({ x: prop(), y: prop(), })) { @modelAction setXY(x: T, y: T) { this.x = x this.y = y } } @model("myApp/Generic3dPoint") class Generic3dPoint extends ExtendedModel(() => ({ baseModel: modelClass>(GenericPoint), props: { z: prop(), }, })) { // ... } ``` If you rely on `tProp` (and also `prop` really) a different possibility is to use a factory pattern with class models. For example: ```ts function createModelClass(modelName: string, initialX: TX, initialY: TY) { @model(`myApp/${modelName}`) class MyModel extends Model({ x: prop(() => initialX), y: prop(() => initialY), }) { @modelAction setXY(x: TX, y: TY) { this.x = x this.y = y } } return MyModel } const NumberMyModel = createModelClass("NumberMyModel", 10, 20) type NumberMyModel = InstanceType const numberMyModelInstance = new NumberMyModel({}) // this will be of type `NumberMyModel` numberMyModelInstance.setXY(50, 60) const StringMyModel = createModelClass("StringMyModel", "10", "20") type StringMyModel = InstanceType 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: ```ts export function createModelClass(modelName: string, initialX: TX, initialY: TY) { const MyModelProps = Model({ x: prop(() => initialX), y: prop(() => 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: ```ts @model("MyApp/Point") class Point extends Model({ x: prop(), y: prop(), }) { get sum() { return this.x + this.y } } // note how `ExtendedModel` is used @model("MyApp/Point3d") class Point3d extends ExtendedModel(Point, { z: prop(), }) { 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: ```ts class X extends ExtendedModel(modelClass>(SomeGenericClass), { ... }) { ... } ``` If you don't it will still compile, but the generic will be assumed to have `unknown` for all its generic parameters. ## Snapshot pre/post-processors: `fromSnapshotProcessor` / `toSnapshotProcessor` `fromSnapshotProcessor` might be used to transform an input snapshot into the model's expected input snapshot. This is useful, for example, for versioning. Note that the `$modelType` property will always be there and will remain unchanged no matter the transformation. ```ts // we split it here so it is accessible to `FromSnapshotDefaultType<>` const modelProps = { // in version 2 we split `fullName` into `firstName` and `lastName` _version: prop(2), firstName: prop(), lastName: prop(), } @model("name") class Name extends Model(modelProps, { fromSnapshotProcessor( sn: FromSnapshotDefaultType | { _version: 1; fullName: string } ) { if (sn._version === 2) { return sn } const [firstName, lastName] = sn.fullName.split(" ") return { _version: 2, firstName, lastName, } }, }) { // ... } ``` `toSnapshotProcessor` is the opposite and might be used to transform the model's expected output snapshot into another kind of object snapshot. Note that the `$modelType` property will always be there and will remain unchanged no matter the transformation. ```ts @model("name") class Name extends Model( { firstName: prop(), lastName: prop(), }, { toSnapshotProcessor(sn, modelInstance) { // we want to also keep `fullName` for backwards compatibility return { ...sn, fullName: `${sn.firstName} ${sn.lastName}`, } }, } ) { // ... } ``` ## Snapshot pre/post-processors for model properties Model properties can have their own snapshot processors as well: In this example we use snapshot processors to serialize a string array property as a comma-separated string. ```ts class M extends Model({ names: prop(() => []) .withSnapshotProcessor({ fromSnapshot: (sn: string) => sn.split(",") toSnapshot: (sn) => sn.join(",") }) }) ``` ## 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` function as shown below: ```ts // note the `_` at the beginning of the name to distinguish it from the decorated version class _Todo extends Model({ text: prop(), done: prop(false), }) { // 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}` } } 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", _Todo, , // 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`, type a variable as `Todo`, etc. type Todo = _Todo // if `_Todo` was generic then it would be `type Todo = _Todo` const myTodo = new Todo({ done: false, text: "buy some milk" }) ``` --- # dataModels ## Overview Data models, like class models, define the behaviors (actions/views) that can be performed over data, but without tainting the data itself with `$modelType`. This however comes with some disadvantages as well, namely: - The model instances are created lazily and when needed rather than eagerly. - The only life-cycle event hook available, `onLazyInit`, is run lazily, this is, the first time the data model wrapper is created, rather than eagerly. - Reconciliation is somewhat worse due to the lack of an ID property to uniquely identify the instances. That being said, they have some use cases (for example to represent a backend response that does not include `$modelType` yet needs to be modified locally and eventually sent back). ## Your first data model Data models are defined in a similar way to class models, except that they use `DataModel` instead of `Model`. One thing to note though is that default values for properties are only applied when using `new` over plain objects (i.e. not tree nodes): ```ts // 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 DataModel({ // 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(), // 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()` // if we required runtime type checking we could do this // text: tProp(types.string), // done: tProp(types.boolean, false), // if you want to make an optional property then use `x: tProp(types.maybe(TYPE))` }) { // the `modelAction` decorator marks the method 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(options?: ModelOptions)` - A property of a given type, with no default set if it is `null` or `undefined` in the initial data passed to `new`. - `prop(defaultValue: T, options?: ModelOptions)` - A property of a given type, with a default set if it is `null` or `undefined` in the initial data passed to `new`. Use this only for default primitives. - `prop(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 passed to `new`. 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 passed to `new`. - `tProp(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 passed to `new`. Use this only for default primitives. - `tProp(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 passed to `new`. Usually used for default objects / arrays / models. ## Data model rules The rules that need to be followed to declare a data model are: - Data models have to be decorated with `@model` and require a unique across-application ID for the model type. - They have to extend `DataModel`, 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. Note that there is one more rule that really sets it apart from class models. Data models are conceptually wrappers around actual data object nodes. This means that when creating an instance via `new` you are really creating a wrapper over the data node (or a new data node if it was not one). Also this means that you can't insert the model itself into a tree, but that you must insert the data being wrapped instead (accessible through `model.$`). ## Creating a data model instance An instance of the todo data node plus its wrapper model can be created like this: ```ts const myTodo1 = new Todo({ done: true, text: "buy some milk" }) // `myTodo1.$` will hold the data object that can be inserted into a tree ``` Note that if the input data is a tree node then `myTodo1.$` will be exactly that same data tree node passed in the constructor. If it is not a tree node then `myTodo1.$` will be the `toTreeNode` version of the passed data object. Also, multiple calls to `new` over a same data tree node will return the same model instance every time. All this means that usually you will just pass the data around and only do a `new` over the data whenever you need to modify it. Some examples: ```ts const todoList: ModelData = [...]; // usually we would use `todoList[x]` to access the data ... // until the moment we want to edit a particular todo const editableTodo = new Todo(todoList[x]); editableTodo.setText("hi there") // once done we can just "throw away" the editable instance ``` ```ts const todoList: ModelData = [...]; const newTodo = new Todo({ done: false, text: "" }) // ... newTodo.setText("buy milk") // note how we insert into the tree the data, not the model itself! todoList.push(newTodo.$); ``` ## Automatic class model actions for property setters Most times, the only action we need for a property is a setter. We can use the prop modifier `withSetter()` (`withSetter("assign")` has been deprecated) to reduce boilerplate and generate property setters. For example, the model above could be written as: ```ts @model("myCoolApp/Todo") export class Todo extends Model({ text: prop().withSetter(), done: prop().withSetter(), }) {} const myTodo = new Todo({ text: "buy some coffee", done: false }) // this is now allowed and properly wrapped in two respective actions myTodo.setText("buy some milk") myTodo.setDone(true) ``` If for some reason you still require to change these without using a `modelAction` consider using `objectActions.set`, `objectActions.delete`, `objectActions.call`, `arrayActions.push`, etc. if needed. ## Life-cycle event hooks Data models only support a single limited life-cycle event hook: - `onLazyInit()`, which is called the first time `new` is called to wrap a certain data node in the life-time of the application. If you need something that runs more consistently consider using `onChildAttachedTo` over the data node parent itself. ## 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. ```ts @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 } } ``` Note that this runtime data holds to the same lazy creation rules as the data model wrapper instance itself. ## Getting the TypeScript types for model data - `ModelData` is the type of the model props without transformations (as accessible via `model.$`). For example `ModelData` would return `{ text: string; done: boolean; }`. ## Flows (async actions) While `@modelAction` defines sync model actions, async model actions are possible as well with the use of `@modelFlow`: ```ts interface Book { title: string price: number } @model("myApp/BookStore") class BookStore extends DataModel({ books: prop(() => []), }) { // 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 / Generics If you are _not_ relying on `tProp` to do runtime type checking it is possible to use this pattern to get generic classes: ```ts @model("myApp/GenericPoint") class GenericPoint extends DataModel(() => ({ x: prop(), y: prop(), })) { @modelAction setXY(x: T, y: T) { this.x = x this.y = y } } @model("myApp/Generic3dPoint") class Generic3dPoint extends ExtendedDataModel(() => ({ baseModel: modelClass>(GenericPoint), props: { z: prop(), }, })) { // ... } ``` If you rely on `tProp` (and also `prop` really) a different possibility is to use a factory pattern with data models. For example: ```ts function createModelClass(modelName: string, initialX: TX, initialY: TY) { @model(`myApp/${modelName}`) class MyModel extends DataModel({ x: prop(() => initialX), y: prop(() => initialY), }) { @modelAction setXY(x: TX, y: TY) { this.x = x this.y = y } } return MyModel } const NumberMyModel = createModelClass("NumberMyModel", 10, 20) type NumberMyModel = InstanceType const numberMyModelInstance = new NumberMyModel({}) // this will be of type `NumberMyModel` numberMyModelInstance.setXY(50, 60) const StringMyModel = createModelClass("StringMyModel", "10", "20") type StringMyModel = InstanceType 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: ```ts export function createModelClass(modelName: string, initialX: TX, initialY: TY) { const MyModelProps = DataModel({ x: prop(() => initialX), y: prop(() => 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 data models that extend from other data models must use `ExtendedDataModel` rather than the plain `DataModel`. For example: ```ts @model("MyApp/Point") class Point extends DataModel({ x: prop(), y: prop(), }) { get sum() { return this.x + this.y } } // note how `ExtendedModel` is used @model("MyApp/Point3d") class Point3d extends ExtendedDataModel(Point, { z: prop(), }) { get sum() { return super.sum + this.z } } ``` Also, remember that if your base model has `onLazyInit` and you redeclare it in your extended model you will need to call `super.onLazyInit(...)` 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: ```ts class X extends ExtendedDataModel(modelClass>(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` function as shown below: ```ts // note the `_` at the beginning of the name to distinguish it from the decorated version class _Todo extends DataModel({ text: prop(), done: prop(), }) { // 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}` } } 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", _Todo, , // 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`, type a variable as `Todo`, etc type Todo = _Todo // if `_Todo` was generic then it would be `type Todo = _Todo` const myTodo = new Todo({ done: false, text: "buy some milk" }) ``` --- # standardAndStandaloneActions ## Standalone Actions Sometimes you might need to define a "model" action but without an associated model. Say for example that you need an array swap method that needs to be processed by middlewares (e.g. [`undoMiddleware`](./actionMiddlewares/undoMiddleware.mdx)). One way to achieve this is to use standalone actions like this: ```ts const arraySwap = standaloneAction( "myApp/arraySwap", (array: T[], index1: number, index2: number): void => { if (index2 < index1) { ;[index1, index2] = [index2, index1] } // since a same node cannot be in two places at once we will remove // both then reinsert them const [v1] = array.splice(index1, 1) const [v2] = array.splice(index2 - 1, 1) array.splice(index1, 0, v2) array.splice(index2, 0, v1) } ) ``` Note the following prerequisites apply to standalone actions: - The name provided must be unique across your whole application. - The first argument (the target) must always be an existing tree node. ## Standard Actions In order to work over objects and arrays without requiring declaring custom actions you can use the already predefined `objectActions` and `arrayActions` (note these also work over class models). `objectActions` work over any kinds of objects (including models themselves) and offer: - `set(obj, key, value)` to set a key. - `delete(obj, key)` to delete a key. - `assign(obj, partialObj)` to assign values (similar to `Object.assign`). - `call(methodName, ...args)` to call a method. `arrayActions` work over arrays and offer: - `set(array, index, value)` to set an index. - `delete(array, index)` to delete an index. - `setLength(array, length)` to set a new length. - `swap(array, index1, index2)` to swap two array elements. Plus the usual array mutation methods (`pop`, `push`, etc.). --- # treeLikeStructure ## Overview `mobx-keystone`'s structure is based on a tree-like structure, where each node can be one of: - A model instance. - A plain object. - An array. - A primitive value (`string`, `boolean`, `number`, `null`, `undefined`). About arrays, it is interesting to note that by default they _cannot_ hold `undefined` values, but they _can_ hold `null` values. This rule is enforced to ensure compatibility with JSON. If you really need arrays with `undefined` values, it can be enabled in the global configuration: ```ts setGlobalConfig({ allowUndefinedArrayElements: true, }) ``` Since the structure is a tree, this means these tree rules apply: 1. A non-primitive (object) node can have zero or one parent. 2. A non-primitive (object) node can have zero to infinite children. 3. From rule 1 and 2 we can extract that a same non-primitve node can only be in a single tree and only once. 4. Primitive nodes are always copied by value (as usual in JavaScript), so none of the rules above apply. 5. Note that class models with the `valueType: true` option will get cloned automatically before getting inserted as a child of another node so, for all practical purposes, rule 3 does not apply and acts more akin to a primitive. As an example of rule 1, this would not be allowed: ```ts // given `someModel`, `someOtherModel`, `someArray` // ok, `someArray` has now one parent and becomes a tree node object someModel.setArray(someArray) // but this would throw since `someArray` is already a tree node object which already has one parent someOtherModel.setArray(someArray) ``` But as rule 4 states, this would be ok: ```ts // given `someModel`, `someOtherModel` const somePrimitive = "hi!" // ok, the primitive is copied, and has now one parent someModel.setPrimitive(somePrimitive) // ok too, since the primitive is copied again, and has one parent someOtherModel.setPrimitive(somePrimitive) ``` A way to work around rule 1 is possible thanks to the use of references as shown in the [references](./references.mdx) section. ## How objects are transformed into nodes A model/object/array is turned into a tree node under the following circumstances: - Model instances are _always_ tree nodes. - Plain objects / arrays are turned into tree nodes as soon as they become children of another tree node. To check if a non-primitive has been turned into a tree node you can use `isTreeNode(value: object): boolean`, or `assertIsTreeNode(value: object, argName: string = "argument"): asserts value is object` to assert it. To turn a non-primitive into a tree node you can use `toTreeNode(value: T): T`. If the object is already a tree node then the same object will be returned. Additionally, `toTreeNode(type: TType, value: V): V` can be used with a type checker which will be invoked to check the data (when auto model type checking is enabled) if desired. ## Traversal methods When a non-primitive value is turned into a tree node it gains access to certain methods that allow traversing the data tree: ### `getParentPath` ```ts getParentPath(value: object): ParentPath | undefined ``` Returns the parent of the target plus the path from the parent to the target, or `undefined` if it has no parent. ### `getParent` ```ts getParent(value: object): T | undefined ``` Returns the parent object of the target object, or `undefined` if there's no parent. ### `getParentToChildPath` ```ts getParentToChildPath(fromParent: object, toChild: object): Path | undefined ``` Gets the path to get from a parent to a given child. Returns an empty array if the child is actually the given parent or `undefined` if the child is not a child of the parent. ### `isModelDataObject` ```ts isModelDataObject(value: object): boolean ``` Returns `true` if a given object is a model interim data object (`$`). ### `getRootPath` ```ts getRootPath(value: object): RootPath ``` Returns the root of the target, the path from the root to get to the target and the list of objects from root (included) until target (included). ### `getRoot` ```ts getRoot(value: object): T ``` Returns the root of the target object, or itself if the target is a root. :::warning Detached nodes If a node gets detached from its parent tree, `getRoot(node)` will return that same node. This matters in reactive code (`computed`, `autorun`, etc.). If a computed uses `getRoot(this)` and then reads a property that resolves back to the same computed, MobX will throw a cycle error. When you need to access an actual registered root store, prefer `getRootStore` and guard for `undefined`: ### `isRoot` ```ts isRoot(value: object): boolean ``` Returns `true` if a given object is a root object. ### `isChildOfParent` ```ts isChildOfParent(child: object, parent: object): boolean ``` Returns `true` if the target is a "child" of the tree of the given "parent" object. ### `isParentOfChild` ```ts isParentOfChild(parent: object, child: object): boolean ``` Returns `true` if the target is a "parent" that has in its tree the given "child" object. ### `resolvePath` ```ts resolvePath(pathRootObject: object, path: Path): { resolved: true; value: T } | { resolved: false } ``` Resolves a path from an object, returning an object with `{ resolved: true, value: T }` or `{ resolved: false }`. ### `findParent` ```ts findParent(child: object, predicate: (parent: object) => boolean, maxDepth = 0): T | undefined ``` Iterates through all the parents (from the nearest until the root) until one of them matches the given predicate. If the predicate is matched it will return the found node. If none is found it will return `undefined`. A max depth of 0 is infinite, but another one can be given. ### `findParentPath` ```ts findParentPath(child: object, predicate: (parent: object) => boolean, maxDepth = 0): FoundParentPath | undefined ``` Iterates through all the parents (from the nearest until the root) until one of them matches the given predicate. If the predicate is matched it will return the found node and the path from the parent to the child. If none is found it will return `undefined`. A max depth of 0 is infinite, but another one can be given. ### `findChildren` ```ts findChildren(root: object, predicate: (node: object) => boolean, options?: { deep?: boolean }): ReadonlySet ``` Iterates through all children and collects them in a set if the given predicate matches. Pass the options object with the `deep` option (defaults to `false`) set to `true` to get the children deeply or `false` to get them shallowly. ### `getChildrenObjects` ```ts getChildrenObjects(node: object, options?: { deep?: boolean }): ReadonlySet ``` Returns an observable set with all the children objects (this is, excluding primitives) of an object. Pass the options object with the `deep` option (defaults to `false`) set to `true` to get the children deeply or `false` to get them shallowly. ### `walkTree` ```ts walkTree(target: object, predicate: (node: any) => T | undefined, mode: WalkTreeMode): T | undefined ``` Walks a tree, running the predicate function for each node. If the predicate function returns something other than `undefined` then the walk will be stopped and the function will return the returned value. The mode can be one of: - `WalkTreeMode.ParentFirst` - The walk will be done parent (roots) first, then children. - `WalkTreeMode.ChildrenFirst` - The walk will be done children (leaves) first, then parents. ## Utility methods ### `detach` ```ts detach(value: object): void ``` Besides the aforementioned `isTreeNode`, `assertIsTreeNode` and `toTreeNode` functions, there's also the `detach(value: object)` function, which allows a node to get detached from its parent following this logic: - If the parent is an object / model, detaching will delete the property. - If the parent is an array detaching will remove the node by splicing it. - If there's no parent it will throw. ### `onChildAttachedTo` ```ts onChildAttachedTo(target: () => object, fn: (child: object) => (() => void) | void, options?: { deep?: boolean, fireForCurrentChildren?: boolean }): (runDetachDisposers: boolean) => void ``` Runs a callback every time a new object is attached to a given node. The callback can optionally return a disposer which will be run when the child is detached. The optional `options` parameter accepts an object with the following options: - `deep: boolean` (default: `false`) - `true` if the callback should be run for all children deeply or `false` if it it should only run for shallow children. - `fireForCurrentChildren: boolean` (default: `true`) - `true` if the callback should be immediately called for currently attached children, `false` if only for future attachments. Returns a disposer, which has a boolean parameter which should be `true` if pending detachment callbacks should be run, or `false` otherwise. ### `onDeepChange` ```ts onDeepChange(target: object, listener: OnDeepChangeListener): OnDeepChangeDisposer ``` `onDeepChange` allows you to observe raw MobX change information for a tree node and all its children. This is useful when you need to detect the actual operation that occurred, such as array splices: ```ts const disposer = onDeepChange(todo, (change) => { // change contains the raw change info including proper splice detection if (change.type === "splice") { console.log(`Array at ${change.path} had ${change.removedCount} items removed and ${change.addedCount} items added at index ${change.index}`) } }) ``` The `DeepChange` type is a discriminated union of: - `ArraySpliceChange` - array splice with `index`, `addedCount`, `removedCount` - `ArrayUpdateChange` - direct array index assignment with `index`, `newValue` - `ObjectAddChange` - property added with `key`, `newValue` - `ObjectUpdateChange` - property updated with `key`, `newValue` - `ObjectRemoveChange` - property removed with `key` Each change includes a `path` array indicating the path from the listener target to the changed object, and a `target` reference to the actual object that changed. Note that the listener callback is called _immediately_ after an observable value has changed and before the outermost action has completed. This behavior differs from, e.g., `onSnapshot` or a MobX reaction. ### `applySet` ```ts applySet(node: O, fieldName: K, value: V): void ``` Allows setting an object/model field / array index to a given value without the need to wrap it in `modelAction`. Unlike `runUnprotected`, this is actually an action that can be captured and replicated. ```ts applySet(someModel, "prop", "value") ``` ### `applyDelete` ```ts applyDelete(node: O, fieldName: K): void ``` Allows deleting an object field / array index without the need to wrap it in `modelAction`. Unlike `runUnprotected`, this is actually an action that can be captured and replicated. ```ts applyDelete(someObject, "field") ``` ### `applyMethodCall` ```ts applyMethodCall(node: O, methodName: K, ...args: Parameters : ReturnType ``` Allows calling an model/object/array method without the need to wrap it in `modelAction`. Unlike `runUnprotected`, this is actually an action that can be captured and replicated. ```ts const newArrayLength = applyMethodCall(someArray, "push", 1, 2, 3) ``` ### `deepEquals` ```ts deepEquals(a: any, b: any): boolean ``` Deeply compares two values. Supported values are: - Primitives - Boxed observables - Objects, observable objects - Arrays, observable arrays - Typed arrays - Maps, observable maps - Sets, observable sets - Tree nodes (optimized by using snapshot comparison internally) Note that in the case of models the result will be false if their model IDs are different. --- # rootStores ## Overview Usually an application has one or more store objects which are meant to represent the actual current state of such application. These objects are usually known as "root stores". In the case of `mobx-keystone`, root stores are tree nodes (model instances or arrays / plain objects turned into tree nodes with `toTreeNode`) from where the rest of the application state will be stored in a tree-like structure. While it is not strictly necessary to mark these instances as root stores, doing so opens up some benefits: - Root stores allow the usage of the `onAttachedToRootStore(rootStore)` hook inside models. You can think of a tree node marked as a root store as the "live tree" of your application state, meaning that any nodes attached to a root store are actually part of your running application, rather than a transient instance that might or might not end up as part of your actual application state. Registering/unregistering a model instance as a root store is as simple as: ```ts // given `myTodoList` is a model instance of a todo list ... registerRootStore(myTodoList) // unregistering unregisterRootStore(myTodoList) ``` ## `onAttachedToRootStore` By registering the instance above the first thing that will happen is that the `onAttachedToRootStore` hook will be invoked for the todo list model as well as any submodels that it might contain. Additionally, any models that eventually get added to the tree will invoke such hook too. This life-cycle hook also supports optionally returning a disposer function, which will execute when the model instance has just left the root store tree or when the root store itself is unregistered. This hook is a great place to actually register effects (e.g. MobX `reaction`, `when`, etc.), and its disposer is a great place to dispose of them. ### Practical example As a practical example, say that you have some kind of application user preferences that have to be saved to / loaded from local storage, but we also want to use the same model in a form to edit them. First we need to define a model for the user preferences, as well as its desired side effects when it is part of the actual application state (when it is part of a root store tree) ... ```ts type Theme = "light" | "dark" @model("myApp/UserPreferences") class UserPreferences extends Model({ theme: prop().withSetter() }) { // once we are part of the root store ... onAttachedToRootStore() { // every time the snapshot of the configuration changes const reactionDisposer = reaction( () => getSnapshot(this), (sn) => { // save the config to local storage localStorage.set("myPreferences", JSON.stringify(sn)) }, { // also run the reaction the first time fireImmediately: true, } ) // when the model is no longer part of the root store stop saving return () => { reactionDisposer() } } } ``` ... we also need to model the root store of our application ... ```ts @model("myApp/RootStore") class RootStore extends Model({ userPreferences: prop().withSetter(), }) {} ``` ... then we will need some code to initialize our application, loading the preferences already stored in local storage ... ```ts const myPreferencesObj = JSON.parse(localStorage.get("myPreferences")) // this will create a `UserPrefernces` model instance, but won't save any changes yet // since it is not yet part of a root store // this means we can manipulate it without fear of overwriting the // config in local storage const myPreferences = fromSnapshot(myPreferencesObj) ``` ... and finally creating the root store itself with the initial data ... ```ts const myRootStore = new RootStore({ userPreferences: myPreferences }) // after this next function is called, `myPreferences` will become part of a root store // and therefore start saving now and on changes registerRootStore(myRootStore) // the preferences get saved ... ``` Now we would like to have a form which will be able to edit a copy of the current user preferences ... ```ts // we make a clone of the current preferences const formPreferences = clone(myRootStore.userPreferences) // since the clone is outside the root store it WON'T be auto-saved // the form eventually makes changes ... formPreferences.setTheme("dark") // but that's ok, it is not auto-saved since it is not part of a root store, // therefore living "outside" the actual application state ``` ... but it should be saved once the save button is clicked: ```ts myRootStore.setUserPreferences(formPreferences) ``` After that last line, the old preferences object (`myPreferences`) will become detached from the root store tree and therefore will stop saving changes (by running the disposer). At the same time the new preferences object (`formPreferences`) will become part of the root store tree, running the hook and therefore saving its data and reacting to changes by saving any future changes. As you can see, such hook is really a great place to manage side effects. ## Sharing contextual data Although usually contexts are preferred for this case (see the [contexts](./contexts.mdx) section), root stores can be also an alternative to store global contextual/environmental runtime data that doesn't really need to be serialized anywhere yet does need to be shared across the whole application. For this we would just need to follow a pattern like this one: In reactive code, prefer `getRootStore` over `getRoot` when you need the application root store. Detached nodes can make `getRoot(node)` resolve to `node` itself. ```ts @model("myApp/RootStore") class RootStore extends Model({...}) { myEnvData!: { ... } } const rootStore = new RootStore({...}) rootStore.myEnvData = { ... } registerRootStore(rootStore) // then on another model class ... extends Model({ ... }) { // on some getter or method ... something() { const rootStore = getRootStore(someModel) const myEnvData = rootStore && rootStore.myEnvData } // or ... onAttachedToRootStore(rootStore) { const myEnvData = rootStore.myEnvData } } ``` --- # snapshots ## Overview Snapshots are immutable, structurally shared, representations of tree nodes (models and their children). Snapshots in `mobx-keystone` mainly serve these two purposes: - As a serialization / deserialization mechanism (be it to store it or send it over the wire). - As a way to bridge data to non-`mobx-react`-enabled React components. Basically, when a change is performed over a tree node then a new immutable snapshot of it will be generated. Additionally, immutable snapshots for all parents will be generated as well. Any unchanged objects however will keep their snapshots unmodified. For example, imagine a model `A` with two children (`B` and `C`), and let's call their initial snapshots `sA[0]`, `sB[0]` and `sC[0]`. ``` A -> sA[0] = getSnapshot(A) B -> sB[0] = getSnapshot(B) C -> sC[0] = getSnapshot(C) ``` If we change a property in `B` then a new snapshot will be generated for it, as well as for all its parents (`A`), but not for unaffected objects (`C` in this case), thus resulting in: ``` A -> sA[1] = getSnapshot(A) B -> sB[1] = getSnapshot(B) C -> sC[0] = getSnapshot(C) ``` This means, as mentioned before, that snapshots generation is automatically optimized to only change their references when the objects they represent (and their children) actually change. Note: Never change the contents of a snapshot object returned by `getSnapshot` directly, clone it first! If you do weird things might happen. ## Snapshot utilities ### `getSnapshotModelType` ```ts getSnapshotModelType(snapshot: unknown): string | undefined ``` Gets the model type name (`$modelType`) from a snapshot. Returns `undefined` if the value is not a model snapshot. ```ts const sn = getSnapshot(todo) const typeName = getSnapshotModelType(sn) // "myApp/Todo" // Check if something is a model snapshot if (getSnapshotModelType(sn) !== undefined) { // it's a model snapshot } ``` ### `getSnapshotModelId` ```ts getSnapshotModelId(snapshot: unknown): string | undefined ``` Gets the model ID from a model snapshot. This reads the value of the ID property as declared with `idProp`. ## Getting the snapshot of an instance ### `getSnapshot` ```ts getSnapshot(value: T): SnapshotOutOf ``` Getting the snapshot out of any tree node is as easy as this: ```ts @model("myApp/Todo") class Todo extends Model({ done: prop(false), text: prop() }) { } const todo = new Todo({ text: "buy some milk" }) const todoSnapshot = getSnapshot(todo) // this returns an object like { done: false, text: "buy some milk", $modelType: "myApp/Todo" } ``` The additional `$modelType` property is used to allow `fromSnapshot` to recognize the original class and faithfully recreate it, rather than assume it is a plain object. This metadata is only required for models, in other words, arrays, plain objects and primitives don't have this extra field. The type returned by `getSnapshot` is strongly typed, and is `SnapshotOutOf` in this case, which in this particular case evaluates as: ```ts type SnapshotOutOf = { done: boolean type: string $modelType: string } ``` Note that `getSnapshot` can actually be used over any tree nodes (any model, or any plain object or array as long as at any point in time they become attached to a model or they are manually transformed into one via `toTreeNode`), as well as primitives (though in the case of primitives the primitive will be returned directly). ## Turning a snapshot back into an instance ### `fromSnapshot` ```ts fromSnapshot(sn: SnapshotInOf, options?: FromSnapshotOptions): T // or typed fromSnapshot(type: TType, sn: SnapshotInOf>, options?: FromSnapshotOptions): TypeToData ``` Restoring a snapshot is pretty easy as well: ```ts const todo = fromSnapshot(todoSnapshot) // or typed const todo = fromSnapshot(Todo, todoSnapshot) ``` The type accepted by `fromSnapshot` is strongly typed as well, and is `SnapshotInOf` in this case, which in this particular case evaluates as: ```ts type SnapshotInOf = { done?: boolean type: string $modelType: string } ``` Compared to the output snapshot note how `done` is now marked as optional since we declared a default value for it. As for the options object, these options are available: - `generateNewIds: boolean` - Pass `true` to generate new internal IDs for models rather than reusing them (default is `false`). ## Reacting to snapshot changes Snapshots are observable values in themselves, which means standard MobX reactions such as this one work: ```ts const disposer = reaction( () => getSnapshot(todo), (todoSnapshot) => { // do something } ) ``` ### `onSnapshot` ```ts onSnapshot(obj: T | () => T, listener: (sn: SnapshotOutOf, prevSn: SnapshotOutOf) => void): () => void ``` Since that is a very common pattern, `mobx-keystone` offers an `onSnapshot` function that will call a listener with the new snapshot and the previous snapshot every time it changes. ```ts const disposer = onSnapshot(todo, (newSnapshot, previousSnapshot) => { // do something }) ``` In both cases the returned disposer function can be called to cancel the effect. ## Applying snapshots ### `applySnapshot` ```ts applySnapshot(obj: T, sn: SnapshotInOf | SnapshotOutOf): void ``` It is also possible to apply a snapshot over an object, reconciling the contents of the object and therefore ensuring that only the minimal set of snapshot changes / patches is triggered: ```ts // given `todo` is a todo with `{ done: false, text: "buy some milk" }` applySnapshot(todo, { done: true, text: "buy some milk", $modelType: todo.$modelType, }) ``` In the case above, only a single patch will be generated (for the `done` property), and the same todo instance will be reused (since they have the same model ID). ## Cloning via snapshots ### `clone` ```ts clone(value: T, options?: CloneOptions): T ``` Snapshots can also be used to clone values. `clone` is just sugar syntax around `getSnapshot` and `fromSnapshot` with `generateNewIds` set to `true` by default. ```ts const clonedTodo = clone(todo) ``` --- # patches ## Overview As seen in the previous [snapshots](./snapshots.mdx) section, any change made to a tree node will generate a new snapshot, but this is only one of the two possible ways `mobx-keystone` offers to detect changes. The second way is "patches". Basically, every change will generate two kinds of patches, patches from the previous to the new value (simply known as "patches") and patches from the new to the previous value (known as "inverse patches"). A patch object has this structure: ```ts export interface Patch { readonly op: "replace" | "remove" | "add" readonly path: Path readonly value?: any // value is not available for remove operations } ``` The difference with JSON patches is that the path is an array of strings / numbers rather than a simple string. This makes it faster to parse and use since there is no parsing / splitting involved. ## Getting patches ### `onPatches` ```ts onPatches(target: object, listener: OnPatchesListener): OnPatchesDisposer ``` `onPatches` allows you to access the patches generated for a tree node and all its children like this: ```ts const disposer = onPatches(todo, (patches, inversePatches) => { // ... }) ``` Note that the listener callback is called _immediately_ after an observable value has changed and before the outermost action has completed. This behavior differs from, e.g., `onSnapshot` or a MobX reaction. ### `patchRecorder` ```ts patchRecorder(target: object, opts?: PatchRecorderOptions): PatchRecorder ``` `patchRecorder` is an abstraction over `onPatches` that can be used like this: ```ts const recorder = patchRecorder(todo, options) ``` Where the allowed options are: ```ts /** * Patch recorder options. */ export interface PatchRecorderOptions { /** * If the patch recorder is initially recording when created. */ recording?: boolean /** * An optional callback filter to select wich patches to record/skip. * It will be executed before the event is added to the events list. * * @param patches Patches about to be recorded. * @param inversePatches Inverse patches about to be recorded. * @returns `true` to record the patch, `false` to skip it. */ filter?(patches: Patch[], inversePatches: Patch[]): boolean /** * An optional callback run once a patch is recorded. * It will be executed after the event is added to the events list. * * @param patches Patches just recorded. * @param inversePatches Inverse patches just recorded. */ onPatches?: OnPatchesListener } ``` It will return an interface implementation that allows you to handle patch recording via the following properties: ```ts interface PatchRecorder { /** * Gets/sets if the patch recorder is currently recording. */ recording: boolean /** * Observable array of patching events. */ readonly events: PatchRecorderEvent[] /** * Dispose of the patch recorder. */ dispose(): void } ``` The `PatchRecorderEvent` definition is: ```ts interface PatchRecorderEvent { /** * Target object. */ readonly target: object /** * Recorded patches. */ readonly patches: Patch[] /** * Recorded inverse patches. */ readonly inversePatches: Patch[] } ``` ## Applying patches ### `applyPatches` ```ts applyPatches(obj: object, patches: Patch[] | Patch[][], reverse?: boolean): void ``` It is also possible to apply patches doing this: ```ts applyPatches(todo, patches) ``` as well as in reverse order (usually used for inverse patches): ```ts applyPatches(todo, patches, true) ``` ## Conversion to JSON patches / paths The only difference with the JSON Patch specification is that paths generated by this library are arrays instead of strings. For compatibility reasons the following conversion functions are provided: ### `pathToJsonPointer` ```ts pathToJsonPointer(path: Path): string ``` Converts a path into a JSON pointer. ### `jsonPointerToPath` ```ts jsonPointerToPath(jsonPointer: string): Path ``` Converts a JSON pointer into a path. ### `patchToJsonPatch` ```ts patchToJsonPatch(patch: Patch): JsonPatch ``` Converts a patch into a JSON patch. ### `jsonPatchToPatch` ```ts jsonPatchToPatch(jsonPatch: JsonPatch): Patch ``` Converts a JSON patch into a patch. --- # mapsSetsDates ## Overview Although `mobx-keystone` doesn't support properties which are Maps/Sets/Dates/BigInt directly (for JSON compatibility purposes), you can still simulate them in three ways: 1. The new property transforms. 2. The `ObjectMap` and `ArraySet` collection models. 3. The `asSet` and `asMap` collection wrappers. ## The new property transforms `mobx-keystone` provides out of the box these property transforms: - `timestampToDateTransform()` - Transforms between a `number` and a `Date`. - `isoStringToDateTransform()` - Transforms between a `string` and a `Date`. - `objectToMapTransform()` - Transforms between a `Record` and a `Map`. Note this uses `asMap` internally, so the same limitations described below apply. - `arrayToMapTransform()` - Transforms between a `Array<[K, V]>` and a `Map`. Note this uses `asMap` internally, so the same limitations described below apply. - `arrayToSetTransform()` - Transforms between a `Array` and a `Set`. Note this uses `asSet` internally, so the same limitations described below apply. - `stringToBigIntTransform()` - Transforms between a `string` and a `bigint`. Using a transform is as easy as calling `.withTransform(transform)` as part of a model property definition. For example: ```ts @model(...) class M extends Model({ date: prop().withTransform(timestampToDateTransform()).withSetter() }) {} const m = new M({ date: new Date() }) m.date // `Date` m.setDate(new Date()) // ok m.$.date // `number` getSnapshot(m) // `{ date: number, ... }` ``` Another example: ```ts @model(...) class M extends Model({ map: prop>().withTransform(objectToMapTransform()) }) {} const m = new M({ map: new Map(...) }) m.map // `Map` m.$.map // `Record` getSnapshot(m) // `{ map: Record, ... }` ``` ### Creating your own custom property transform ```ts // first we designate the transform, with the type // `ModelPropTransform` const _timestampToDateTransform: ModelPropTransform = { transform({ originalValue, cachedTransformedValue, setOriginalValue }) { // `originalValue` is the original (number) value to transform // `cachedTransformedValue` is previously transformed value (`Date`) related // to that original value (if any) // `setOriginalValue` can be called in case we want to change the original value return cachedTransformedValue ?? new ImmutableDate(originalValue) }, untransform({ transformedValue, cacheTransformedValue }) { // `transformedValue` is the transformed value (`Date`) // `cacheTransformedValue()` can be called if we want to save into the cache // that the current `transformedValue` can be cached and reused for that particular // original value if (transformedValue instanceof ImmutableDate) { cacheTransformedValue() } return +transformedValue }, } // we need to export it as a function that returns the transform to keep TS happy // whenever a generic is involed (e.g. see the source code of `arrayToSetTransform`) // we will always return the same instance though instead of recreating it every time export const timestampToDateTransform = () => _timestampToDateTransform ``` ## Collection models ### `ObjectMap` collection model ```ts class ... extends Model({ myNumberMap: prop(() => objectMap()) // or if there's no default value myNumberMap: prop>() }) {} ``` All the usual map operations are available (clear, set, get, has, keys, values, ...), and the snapshot representation of this model will be something like: ```ts { $modelType: "mobx-keystone/ObjectMap", $modelId: "Td244...", items: { "key1": value1, "key2": value2, } } ``` ### `ArraySet` collection model ```ts class ... extends Model({ myNumberSet: prop(() => arraySet()) // or if there's no default value myNumberSet: prop>() }) {} ``` All the usual set operations are available (clear, add, has, keys, values, ...), and the snapshot representation of this model will be something like: ```ts { $modelType: "mobx-keystone/ArraySet", $modelId: "Td244...", items: [ value1, value2 ] } ``` ## Collection wrappers **Note: Collection wrappers will return the same collection given a same backed object.** ### `asMap` collection wrapper `asMap` will wrap either an object of type `{ [k: string]: V }` or an array of type `[string, V][]` and wrap it into a `Map` alike interface. If the backed property is an object operations should be as fast as usual. If the backed property is an array the following operations will be slower than usual: - `set` operations will need to iterate the backed array until the item to update is found. - `delete` operations will need to iterate the backed array until the item to be deleted is found. ```ts class ... { // given `myRecord: prop<{ [k: string]: V }>(() => ({}))` get myMap() { return asMap(this.myRecord) } // and if a setter is required @modelAction setMyMap(myMap: Map) { this.myRecord = mapToObject(myMap) } } class ... { // given `myArrayMap: prop<[string, V][]>(() => [])` get myMap() { return asMap(this.myArrayMap) } // and if a setter is required @modelAction setMyMap(myMap: Map) { this.myArrayMap = mapToArray(myMap) } } // then `myMap` can be used as a standard `Map` ``` To convert it back to an object/array you can use `mapToObject(map)` or `mapToArray(map)`. When the map is a collection wrapper it will return the backed object rather than do a conversion. ### `asSet` collection wrapper `asSet` will wrap a property of type `V[]` and wrap it into a `Set` alike interface: Note that, currently, since the backed property is actually an array the following operations will be slower than usual: - `delete` operations will need to iterate the backed array until it finds the value to be deleted. ```ts class ... { // given `myArraySet: prop(() => [])` get mySet() { return asSet(this.myArraySet) } // and if a setter is required @modelAction setMySet(mySet: Set) { this.myArraySet = setToArray(mySet) } } // then `mySet` can be used as a standard `Set` ``` To convert it back to an array you can use `setToArray(set)`. When the map is a collection wrapper it will return the backed object rather than do a conversion. --- # actionMiddlewares/onActionMiddleware This action middleware invokes a listener for all actions of a given tree. Note that the listener will only be invoked for the topmost level actions, so it won't run for child actions or intermediary flow steps. Also it won't trigger the listener for calls to hooks such as `onAttachedToRootStore` or its returned disposer. Its main use is to keep track of top-level actions that can be later replicated via `applyAction` somewhere else (another machine, etc.). There are two kinds of possible listeners, `onStart` and `onFinish` listeners. - `onStart` listeners are called before the action executes and allow cancelation by returning a new return value (which might be a return or a throw). - `onFinish` listeners are called after the action executes, have access to the action's actual return value and allow overriding by returning a new return value (which might be a return or a throw). The actions passed as arguments to the listener are not in a serializable format. If you want to ensure that the actual action calls are serializable you should use `serializeActionCall` over the whole action before sending the action call over the wire / storing them and likewise use `applySerializedActionAndTrackNewModelIds` (for the server) / `applySerializedActionAndSyncNewModelIds` (for the clients) before applying it (as seen in the [Client/Server Example](../examples/clientServer/clientServer.mdx)). It will return a disposer, which only needs to be called if you plan to early dispose of the middleware. ```ts const disposer = onActionMiddleware(myTodoList, { onStart(actionCall, actionContext) { // we could serialize the action call and do something with it const serializableActionCall = serializeActionCall(myTodoList, actionCall) // optionally cancel the action by throwing something return { result: ActionTrackingResult.Throw, value: new Error("whatever"), } // or by returning a different value return { result: ActionTrackingResult.Return, value: 42, } // or do nothing / return `undefined` to continue it }, onFinish(actionCall, actionContext, ret) { if (ret.result === ActionTrackingResult.Return) { // the action succeeded and `ret.value` has the return value } else if (ret.result === ActionTrackingResult.Throw) { // the action threw and `ret.value` has the thrown value } // as in above, we can either return an object with what to return / throw // or do nothing / return `undefined` to continue the action }, }) ``` ## Action serialization with custom types as arguments Action serialization (via `serializeActionCall` and `deserializeActionCall`) supports many cases by default: - Primitives (including `undefined`, `bigint` and special `number` values `NaN`/`+Infinity`/`-Infinity`, but not `symbol`). - Tree nodes as paths if they are under the same root node as the model that holds the action being called. - Tree nodes as snapshots if not. - Arrays and observable arrays. - `Date` objects as timestamps. - Maps and observable maps. - Sets and observable sets. - Plain objects, observable or not. However, you might want to serialize an action that passes your custom type as an argument. In this case you can register a custom action serializer: ```ts const myTypeSerializer: ActionCallArgumentSerializer = { id: "someSerializerUniqueId", serialize(valueToSerialize, serializeChild, targetRoot) { if (valueToSerialize instanceof MyType) { return someJsonCompatibleValue } // let other serializer handle it return cannotSerialize }, deserialize(someJsonCompatibleValue, deserializeChild, targetRoot) { // return back `MyType` from the JSON compatible value }, } registerActionCallArgumentSerializer(myTypeSerializer) ``` In this case, whenever an instance of `MyType` is found as an action argument, then (after using `serializeActionCall` on the action call) the action argument will be serialized as a `SerializedActionCallArgument`: ```ts { $mobxKeystoneSerializer: "someSerializerUniqueId", value: someJsonCompatibleValue } ``` Likewise, using `deserializeActionCall` will transform it back to an instance of `MyType`. --- # actionMiddlewares/transactionMiddleware ## Overview The transaction middleware allows you to mark model actions/flows as transactions, this is, if such action/flow throws then any changes performed during such will be reverted before the exception is actually thrown. There are two ways to mark an action/flow as a transaction. As a decorator and programmatically. As a decorator: ```ts @model("MyApp/MyBalance") class MyBalance extends Model({ balance: prop(), }) { @transaction @modelAction addMoney(cents: number) { this.balance += cents // imagine that something else goes wrong // in this case balance will be reverted to the value that // was there before the action started throw new Error("...") } } ``` Programmatically: ```ts @model("MyApp/MyModel") class MyBalance extends Model({ balance: prop(), }) { @modelAction addMoney(cents: number) { this.balance += cents // imagine that something else goes wrong // in this case balance will be reverted to the value that // was there before the action started throw new Error("...") } // we could for example add it on init (for all instances) onInit() { transactionMiddleware({ model: this, actionName: "addMoney", }) } } // or for a particular instance const myBalance = new MyBalance({ balance: 100 }) transactionMiddleware({ model: myBalance, actionName: "addMoney", }) ``` --- # actionMiddlewares/undoMiddleware ## Overview The undo middleware allows you to keep a history of the changes performed to your data and travel back (undo) and forth (redo) between those changes. For example, given this simple model: ```ts @model("MyApp/Counter") class Counter extends Model({ count: prop(0) }) { @modelAction add(n: number) { this.count += n } } const counter = new Counter({}) ``` We can create an undo manager for it: ```ts const undoManager = undoMiddleware(counter) ``` ## `UndoManager` The returned `undoManager` offers the following data: - `store: UndoStore` - The store currently being used to store undo/redo action events. - `undoQueue: ReadonlyArray` - The undo stack, where the first operation to undo will be the last of the array. - `redoQueue: ReadonlyArray` - The redo stack, where the first operation to redo will be the last of the array. - `undoLevels: number` - The number of undo actions available. - `canUndo: boolean` - If undo can be performed (if there is at least one undo action available). - `redoLevels: number` - The number of redo actions available. - `canRedo: boolean` - If redo can be performed (if there is at least one redo action available). And the following actions: - `clearUndo()` - Clears the undo queue. - `clearRedo()` - Clears the redo queue. - `undo()` - Undoes the last action. Will throw if there is no action to undo. - `redo()` - Redoes the previous action. Will throw if there is no action to redo. - `dispose()` - Disposes of the undo middleware. ## `UndoEvent` Each change is stored as an `UndoEvent`, which is a readonly structure like: - `targetPath: Path` - Path to the object that invoked the action from its root. - `actionName: string` - Name of the action that was invoked. - `patches: ReadonlyArray` - Patches with changes done inside the action. Use `redo()` in the `UndoManager` to apply them. - `inversePatches: ReadonlyArray` - Patches to undo the changes done inside the action. Use `undo()` in the `UndoManager` to apply them. ## Storing the undo store inside your models `undoMiddleware` accepts a second optional parameter. When this parameter is omitted the event store will be just stored on some random model in memory, but if you want it to be stored inside one of your models (for example to persist it), you can do so by passing as second argument where it should be located. ```ts @model("MyApp/MyRootStore") class MyRootStore extends Model({ undoData: prop(() => new UndoStore({})), counter: prop(() => new Counter({})), }) {} const myRootStore = new MyRootStore({}) const undoManager = undoMiddleware(myRootStore, myRootStore.undoData) ``` ## Making some changes skip undo/redo Sometimes you might want some changes / part of changes skip the undo/redo mechanism. To do so you can use the `withoutUndo` method like this: ```ts @modelAction someAction() { // this change will be redone/undone when the action is redone/undone this.x++ // you may skip only in certain undo managers ... someUndoManager.withoutUndo(() => { // this one won't this.y++ }) // or for all of them withoutUndo(() => { // this one won't this.y++ }) // this one will this.z++ } ``` ## Grouping multiple actions into a single undo/redo step Sometimes you might want multiple actions to be undone/redone in a single step. If they are sync actions you may use the `withGroup` method like this: ```ts someUndoManager.withGroup("optional group name", () => { someModel.firstAction() someOtherModel.secondAction() // note how nested groups are allowed someUndoManager.withGroup(() => { someModel.thirdAction() someOtherModel.fourthAction() }) }) ``` If they are async actions then you may use `withGroupFlow` instead: ```ts someUndoManager.withGroupFlow("optional group name", function* () { yield* _await(someModel.firstAsyncAction()) yield* _await(someService.someAsyncStuffInTheMiddle()) yield* _await(someModel.secondAsyncAction()) }) ``` Another possibility is to use `createGroup` to group sync actions in separated code blocks: ```ts const group = someUndoManager.createGroup("optional group name") group.continue(() => { someModel.firstSyncAction() }) const asyncValue = await someService.someAsyncStuffInTheMiddle() group.continue(() => { someModel.secondSyncAction(asyncValue) }) group.end() // at this point is when the undo event will be created ``` Now, once undo/redo is called all the actions will be undone/redone in a single call. ## Limiting the number of undo/redo steps By default there is no limit to the number of undo/redo steps that can be stored. If you want to limit the number of steps you can do so by passing the `maxUndoLevels` and `maxRedoLevels` options as third argument to `undoMiddleware`: ```ts const undoManager = undoMiddleware(myRootStore, undefined, { maxUndoLevels: 50, // or omit to have no limit maxRedoLevels: 50, // or omit to have no limit }) ``` ## Adding attached state to each undo/redo step Imagine a text editor where you don't want to undo each single cursor position change, but you still want to move the cursor to wherever it was before (when undoing) / after (when redoing) an action is performed. For this use case you can use what is called an "attached state". This attached state gets saved before an undo/redo step is recorded, as well as after, and is restored after each undo/redo operation. In the case of the text editor, the "attached state" would be the cursor position. ```ts interface TextEditorAttachedState { cursorPosition: number } const undoManager = undoMiddleware(myRootStore, undefined, { attachedState: { save(): TextEditorAttachedState { return { cursorPosition, // get the cursor position } }, restore(attachedState: TextEditorAttachedState) { // move the cursor position }, }, }) ``` --- # actionMiddlewares/readonlyMiddleware ## Overview Attaches an action middleware that will throw when any action is started over the node or any of the child nodes, thus effectively making the subtree readonly. It will return an object with a `dispose` function to remove the middleware and an `allowWrite` function that will allow actions to be started inside the provided code block. Example: ```ts // given a model instance named `todo` const { dispose, allowWrite } = readonlyMiddleware(todo) // this will throw todo.setDone(false) await todo.setDoneAsync(false) // this will work allowWrite(() => todo.setDone(false)) // note: for async always use one action invocation per `allowWrite`! await allowWrite(() => todo.setDoneAsync(false)) ``` --- # actionMiddlewares/customMiddlewares ## Overview Besides the very specific `onActionMiddleware` (which only tracks top-level actions and is usually used to record actions to be later replicated via `applyAction`), there are two additional ways to create your own custom middleware, the low-level `addActionMiddleware`, which should be rarely needed, and the more high-level but friendlier `actionTrackingMiddleware`. ## `actionTrackingMiddleware` Creates an action tracking middleware, which is a simplified version of the standard action middleware. It takes two parameters, being the first one the root target model object and the second one the hooks in the form of an `ActionTrackingMiddleware` object. It returns a disposer function. The `ActionTrackingMiddleware` object has the following structure: - `filter?(ctx: SimpleActionContext): boolean` Filter function called whenever each action starts, and only then. Takes as parameter a simplified action context (more on that later). Returns `true` to accept the action and `false` to skip it. If the action is accepted then `onStart`, `onResume`, `onSuspend` and `onFinish` for that particular action will be called. All actions are accepted by default if no filter function is present. - `onStart?(ctx: SimpleActionContext): void | ActionTrackingReturn` Called when an action just started. Takes as parameter a simplified action context (more on that later). Can optionally return a result that will cancel the original action and finish it with the returned value / error to be thrown. In either case case resume / suspend / finish will still be called normally. - `onResume?(ctx: SimpleActionContext): void` Called when an action just resumed a synchronous piece of code execution. Gets called once for sync actions and multiple times for flows. Takes as parameter a simplified action context (more on that later). - `onSuspend?(ctx: SimpleActionContext): void` Called when an action just finished a synchronous pice of code execution. Note that this doesn't necessarily mean the action is finished. Gets called once for sync actions and multiple times for flows. Takes as parameter a simplified action context (more on that later). - `onFinish?(ctx: SimpleActionContext, ret: ActionTrackingReturn): void | ActionTrackingReturn` Called when an action just finished, either by returning normally or by throwing an error. Takes as parameters: - `ctx` - Simplified action context (more on that later). - `ret: { result: ActionTackingResult; value: any }` - If the action finished normally or due to a thrown error and the returned / thrown value. Can optionally return a new return / error value to override the result of the action. ### `SimpleActionContext` Simplified version of action context, which includes the following readonly data: - `actionName: string` - Action name. - `type: ActionContextActionType` - Action type, sync or async. - `target: AnyModel` - Action target model instance. - `args: ReadonlyArray` - Array of action arguments. - `parentContext?: SimpleActionContext` - Parent action context, if any. - `rootContext: SimpleActionContext` - Root action context, or itself if the root. - `data: any` - Custom data for the action context to be set by middlewares, an object. It is advised to use symbols as keys whenever possible to avoid name clashing between middlewares. It is simplified as in it treats all single synchronous steps of asynchronous actions as if they were in the same context, therefore eliminating the differences between the action contexts of sync actions and flows. ## `addActionMiddleware` `addActionMiddleware` adds a global action middleware to be run when an action is performed. It takes a single parameter, an `ActionMiddleware` object and returns a disposer function. The `ActionMiddleware` object has the following structure: - `subtreeRoot: object` Subtree (object and child objects) this middleware will run for. This target "filter" will be run before the custom filter. - `filter?(ctx: ActionContext): boolean` A filter function to decide if an action middleware function should be run or not. - `middleware(ctx: ActionContext, next: () => any): any` An action middleware function. Remember to `return next()` if you want to continue the action or throw if you want to cancel it. ### `ActionContext` Low-level action context, which includes the following readonly data: - `actionName: string` - Action name. - `type: ActionContextActionType` - Action type, sync or async. - `target: AnyModel` - Action target model instance. - `args: ReadonlyArray` - Array of action arguments. - `parentContext?: SimpleActionContext` - Parent action context, if any. - `rootContext: SimpleActionContext` - Root action context, or itself if the root. - `previousAsyncStepContext?: ActionContext` - Previous async step context, `undefined` for sync actions or the first action of a flow. - `spawnAsyncStepContext?: ActionContext` - Spawn async step context, or `undefined` for sync actions. - `asyncStepType?: ActionContextAsyncStepType` - Async step type, or `undefined` for sync actions. - `data: any` - Custom data for the action context to be set by middlewares, an object. It is advised to use symbols as keys whenever possible to avoid name clashing between middlewares. --- # contexts ## Overview Contexts serve as a way to share contextual/environmental data deeply across a tree without having to know the exact structure of such tree. Think of it as a dependency injection system. For example, imagine a state where some children need to know the current username to perform certain operations. We could make the children use `getRoot` to access the username, but that means we need a root for unit testing any of the children, therefore, we created a dependency from a child to one of its parents. With contexts we could make it like this: ```ts const usernameCtx = createContext() @model("MyApp/SomeParent") class SomeParent extends Model({ username: prop(), // ... }) { onInit() { usernameCtx.setComputed(this, () => this.username) } } @model("MyApp/SomeDeepChild") class SomeDeepChild extends Model({...}) { @modelAction someActionThatRequiresUsername() { const username = usernameCtx.get(this) // ... } @computed get someComputedThatRequiresUsername() { return usernameCtx.get(this) + " is awesome!" } } ``` With this code, whenever the child is attached to the parent the username will be fetched from the parent. The fact that we can set the context value at any point of the tree also makes it easier to unit test the child model in isolation: ```ts const child = new SomeDeepChild(...) usernameCtx.set(child, "RandomUsername") expect(child.someComputedThatRequiresUsername).toBe("RandomUsername is awesome!") ``` When using `createContext` a default value can be also provided (e.g. `const userCtx = createContext("defaultUsername")`), which will be used when no node higher in the tree provides a value. The returned context object has the following methods: - `getDefault()` - Gets the default context value. - `setDefault(value)` - Sets the (static) default context value. - `setDefaultComputed(() => value)` - Sets the (computed) default context value. - `get(node)` - Gets the context value for a given node (recursing up in the tree until a node has a set value or the default if none is set). Usually called in actions and computed getters. This value is reactive/observable, so never cache this value since it might get stale. - `set(node, value)` - Sets the (static) value a node will provide for itself and its children. Usually called in `onInit`. - `setComputed(node, () => value)` - Sets the (computed) value a node will provide for itself and its children. Usually called in `onInit`. - `unset(node)` - Make the node no longer provide a context value. - `getProviderNode(node)` - Gets the node that will provide the value, or undefined if it will come from the default value. - `apply(fn, value)` - Applies a value override while the given function is running and, if a node is returned, sets the node as a provider of the value. - `applyComputed(fn, () => value)` - Applies a computed value override while the given function is running and, if a node is returned, sets the node as a provider of the computed value. In particular, `apply` is more useful than it may seem, since it allows you to pass volatile (non-property) data to your nodes at construction time (be it through `new`, `fromSnapshot`, `clone`, `toTreeNode`, etc.). For example: ```ts const envCtx = createContext(0) @model(...) class M extends Model({...}) { onInit() { const value = envCtx.get(this) } get value() { return envCtx.get(this) } } const m = envCtx.apply(() => new M({...}), 9000) // onInit's "value" will be 9000 m.value // this will also be 9000 ``` --- # references ## Overview As we saw in the [tree-like structure](./treeLikeStructure.mdx) section, a same non-primitve node can only be in a single tree and only once. This means that, for example, if we wanted to have a list of todos and a selected todo then, in theory, we would need to have the same node repeated twice (once in the list and then once again in a selected field). References allow us to work around this limitation by making a "fake" node that is just a pointer to another object given an ID. ## Root references Root references are references that can be resolved as long as both the reference and the referenced object live under the same tree, this is, as long as they share a common root. They are created like this: ```ts const myRef = rootRef("some unique model type id", { getId?(target: unknown): string | undefined { // given an object (which could or could not be of the target type) // what is its ID? or `undefined` if it has no ID // note that we should only return IDs if our reference should be able to reference them }, onResolvedValueChange?(ref: Ref, newValue: T | undefined, oldValue: T | undefined) { // what should happen when the resolved value changes? }, }) ``` Note that if the reference points to a model and that model class specifies a custom method named `getRefId()` (or you want to use the `idProp` as reference ID, which is the default implementation of `getRefId()`) then `getId` can be omitted. Reference objects can then be created using `myRef(target: T)` or `myRef(id: string)` and offer the following properties: - `isValid` - If the reference is valid (can be currently resolved). - `current` - The object this reference points to, or throws if invalid. - `maybeCurrent` - The object this reference points to, or `undefined` if invalid. ## Custom references Custom references are a bit more powerful than root references, but a bit harder to set up. They are created like this: ```ts const myRef = customRef("some unique model type id", { getId?(target: T): string { // given an object, what is its ID? }, resolve(ref: Ref): T | undefined { // given the `ref` object (which includes the ID in `ref.id`), // how do we get the object back? }, onResolvedValueChange?(ref: Ref, newValue: T | undefined, oldValue: T | undefined) { // what should happen when the resolved value changes? }, }) ``` Again, if the reference points to a model and that model class specifies a method named `getRefId()` then `getId` can be omitted. They can be created exactly the same way as root references and offer the exact same properties. ## Checking if a reference is of a given type `isRefOfType(ref, refType)` can be used to check if a reference object is of a given type. For example, `isRefOfType(myRef(...), myRef)` will return `true`. ## Back-references Sometimes it is useful to get back all references that currently resolve to a given node. For this you can use `getRefsResolvingTo(target, refType?, options?)`, where `target` is the node the references are pointing to, `refType` is an optional argument that when provided will ensure only references of a given type are returned, and `options` is an optional argument for providing additional options. It returns an observable set of reference objects that point to the target. By default, back-references are updated after the outermost action has completed. In case it is necessary to update back-references immediately, the option `updateAllRefsIfNeeded` can be set to `true`. ## Example: Reference to single selected Todo Imagine that we had a todo list where each todo item had a unique `id: string` property, and we could select a single todo item or none. It could be done like this: ```ts // we could use a root reference that makes use of `getRefId()` on models... const todoRef = rootRef("myApp/TodoRef", { // this works, but we will use `getRefId()` from the `Todo` class instead // getId(maybeTodo) { // return maybeTodo instanceof Todo ? maybeTodo.id : undefined // }, onResolvedValueChange(ref, newTodo, oldTodo) { if (oldTodo && !newTodo) { // if the todo value we were referencing disappeared then remove the reference // from its parent detach(ref) } }, }) // ... or a custom reference const todoRef = customRef("myApp/TodoRef", { // we could omit this since `getRefId()` is declared on the `Todo` class // getId(todo) { // return todo.id // }, resolve(ref) { // get the todo list where this ref is const todoList = findParent(ref, (n) => n instanceof TodoList) // if the ref is not yet attached then it cannot be resolved if (!todoList) return undefined // but if it is attached then try to find it return todoList.list.find((todo) => todo.id === ref.id) }, onResolvedValueChange(ref, newTodo, oldTodo) { if (oldTodo && !newTodo) { // if the todo value we were referencing disappeared then remove the reference // from its parent detach(ref) } }, }) @model("myApp/Todo") class Todo extends Model({ id: prop(), // ... }) { getRefId() { // when `getId` is not specified in the custom reference it will use this as id return this.id } // ... } @model("myApp/TodoList") class TodoList extends Model({ list: prop(() => []), selectedRef: prop | undefined>(), }) { // ... // not strictly needed, but neat @computed get selectedTodo() { return this.selectedRef ? this.selectedRef.current : undefined } @modelAction selectTodo(todo: Todo | undefined) { if (todo && !this.list.includes(todo)) throw new Error("unknown todo") this.selectedRef = todo ? todoRef(todo) : undefined } } ``` The good thing is that whenever a todo is removed from the list and it was the selected one, then the `selectedTodo` property will automatically become `undefined`. ## Example: Reference to multiple selected Todos In the case multiple selection was possible we could reuse the `todoRef` created previously and model it like this instead: ```ts @model("myApp/TodoList") class TodoList extends Model({ list: prop(() => []), selectedRefs: prop < Ref < Todo > [] >> (() => []), }) { // ... // not strictly needed, but neat @computed get selectedTodos() { return this.selectedRefs.map((r) => r.current) } @modelAction selectTodo(todo: Todo) { if (!this.list.includes(todo)) throw new Error("unknown todo") if (!this.selectedTodos.includes(todo)) { this.selectedRefs.push(todoRef(todo)) } } @modelAction unselectTodo(todo: Todo) { if (!this.list.includes(todo)) throw new Error("unknown todo") const todoRefIndex = this.selectedRefs.findIndex((todoRef) => todoRef.maybeCurrent === todo) if (todoRefIndex >= 0) { this.selectedRefs.splice(todoRefIndex, 1) } } } ``` Again, if a todo is removed from the list and it was a selected one then it will automatically disappear from the selected todos list. Passing a `Todo` object directly to the select/unselect methods is valid even when using action replication in remote servers, since the serialization of the argument will be automatically transformed to a path to the `Todo` object from the root, plus a path of IDs for validation. This means that when the `Todo` object is inside the same root store as the model parent of the action being called only a minimum set of data will be sent, while only if not, then the whole snapshot will be sent. --- # frozen ## Overview When performance is key and there are big chunks of data that don't change at all we can use `frozen`. `frozen` basically wraps a plain chunk of data (composed of plain objects, arrays, primitives, or a mix of them) inside a model-like structure, turns such data into immutable (in dev mode) and therefore keeps it still observable (by reference), snapshotable, patchable and reactive, but skipping the overhead of turning every single object inside the frozen data into separate tree nodes. Additionally, in dev mode only (for performance reasons), frozen will deeply freeze the object passed to it (ensuring it stays immutable) and will ensure the structure passed is compatible with JSON in order for it to be properly snapshotable. As an example, say that your app uses lists of lots of points (polygons), and you know that once a polygon is added to your store the polygon itself won't change. In order to make it faster it could be modeled like this: ```ts type Polygon = { x: number; y: number }[] // not frozen polygon, `getSnaphot` for example still won't work on it const myPolygon = [ { x: 10, y: 10 }, { x: 20, y: 10 }, ] // now `myPolygon` will be frozen, in dev mode it cannot be changed anymore // and things like `getSnapshot` will work over it const myFrozenPolygon = frozen(myPolygon) // to access the frozen object data we have to use `data` const firstPoint = myFrozenPolygon.data[0] ``` --- # runtimeTypeChecking ## Overview While `mobx-keystone` was built with first-class TypeScript support in mind, it is also possible to enforce runtime type checking. This feature is, however, **completely optional**. This is, if you are happy with the type safety that TypeScript offers at compilation time you are free to stick to it exclusively. ## Type definitions Type definitions are like the schemas for your data. They are usually associated with models like this: ```ts @model("TodoApp/Todo") class Todo extends Model({ text: tProp(types.string), done: tProp(types.boolean, false), }) { // ... } // ModelData = { // text: string, // done: boolean // } ``` In this case whenever the model is created / changed it will be automatically type-checked in development mode and will throw an exception if the change results in a model that does not pass the checking. If you want to enforce checks no matter if `process.env.NODE_ENV` is set to "production" or not you can do so like this: ```ts setGlobalConfig({ modelAutoTypeChecking: ModelAutoTypeCheckingMode.AlwaysOn, }) ``` The possible values are: - `ModelAutoTypeCheckingMode.DevModeOnly` - Auto type-check models only in dev mode - `ModelAutoTypeCheckingMode.AlwaysOn` - Auto type-check models no matter the current environment - `ModelAutoTypeCheckingMode.AlwaysOff` - Do not auto type-check models no matter the current environment It is also possible to trigger type checking manually: ```ts const myTodo = new Todo({ text: "hi" }) const checkError = myTodo.typeCheck() // or const todoType = types.model(Todo) const checkError = typeCheck(todoType, myTodo) // also possible with non-models const numberArrayType = types.array(types.number) const checkError = typeCheck(numberArrayType, [1, 2, 3]) ``` In all cases the returned value will be `null` if there are no errors or an instance of `TypeCheckError`, which will include: - `path: Path` - Sub-path where the type-check failed, or an empty array if the actual object/value failed the type-check. - `expectedTypeName: string` - String representation of the expected type. - `actualValue: any` - The actual value/sub-value that failed the type-check - `throw(typeCheckedValue: any)` - Throws the error as an exception. While models are usually automatically type-checked, it is worth noting that other values (primitives, plain objects, arrays) are not until they become attached to some model. If you need to type-check those before they become attached to a model it is always possible to use `typeCheck(type, value)` as shown previously to trigger a manual validation. ## Types These are the possible types: ### `types.literal` A type that represents a certain value of a primitive (for example an _exact_ number or string). ```ts const hiType = types.literal("hi") // the string with value "hi" const number5Type = types.literal(5) // the number with value 5 ``` ### `types.undefined` / `undefined` A type that represents the value `undefined`. ### `types.null` / `null` A type that represents the value `null`. ### `types.boolean` / `Boolean` A type that represents any boolean value. ### `types.number` / `Number` A type that represents any number value. ### `types.integer` A type that represents any integer number value. ### `types.string` / `String` A type that represents any string value. ### `types.nonEmptyString` A type that represents any string value other than "". ### `types.enum(enumObject)` An enum type, based on a TypeScript alike enum object. ```ts enum Color { Red = "red", Green = "green", } const colorType = types.enum(Color) ``` ### `types.or(...types)` (AKA union) A type that represents the union of several other types (`a | b | c | ...`). ```ts const booleanOrNumberType = types.or(types.boolean, types.number) ``` ### `types.maybe(type)` A type that represents either a type or `undefined`. ```ts const numberOrUndefinedType = types.maybe(types.number) ``` ### `types.maybeNull(type)` A type that represents either a type or `null`. ```ts const numberOrNullType = types.maybeNull(types.number) ``` ### `types.object(() => ({ ... })` A type that represents a plain object. Note that the parameter must be a function that returns an object. This is done so objects can support self / cross types. ```ts // notice the `({ ... })`, not just `{ ... }` const pointType = types.object(() => ({ x: types.number, y: types.number, })) ``` ### `types.array(itemsType)` A type that represents an array of values of a given type. ```ts const numberArrayType = types.array(types.number) ``` ### `types.tuple(...itemTypes)` A type that represents a tuple of values of a given type. ```ts const stringNumberTupleType = types.tuple(types.string, types.number) ``` ### `types.record(valuesType)` A type that represents an object-like map, an object with string keys and values all of a same given type. ```ts // `{ [k: string]: number }` const numberMapType = types.record(types.number) ``` ### `types.model(modelClass)` A type that represents a model. The type referenced in the model decorator will be used for type checking. If you use recursive / cross referencing models and get TypeScript errors then consider using the lambda parameter instead. ```ts const someModelType = types.model(SomeModel) // or for recursive models const someModelType = types.model(() => SomeModel) ``` Note that most times just passing the model as type works (for example, `tProp(SomeModel)` and `tProp(types.model(SomeModel))` are equivalent). ### `types.dataModelData(dataModelClass)` A type that represents the data of a data model. The type referenced in the model decorator will be used for type checking. If you use recursive / cross-referencing models and get TypeScript errors then consider using the lambda parameter instead. ```ts const someModelDataType = types.dataModelData(SomeModel) // or for recursive models const someModelDataType = types.dataModelData(() => SomeModel) ``` ### `types.unchecked()` A type that represents a given value that won't be type-checked. This is basically a way to bail out of the runtime type checking system. ```ts const uncheckedSomeModel = types.unchecked() const anyType = types.unchecked() const customUncheckedType = types.unchecked<(A & B) | C>() ``` ### `types.ref(refConstructor)` A type that represents a reference to an object or model. ```ts const refToSomeObject = types.ref(SomeObject) ``` ### `types.frozen(type)` A type that represents frozen data. ```ts const frozenNumberType = types.frozen(types.number) const frozenAnyType = types.frozen(types.unchecked()) const frozenNumberArrayType = types.frozen(types.array(types.number)) const frozenUncheckedNumberArrayType = types.frozen(types.unchecked()) ``` ### `types.objectMap(valuesType)` A type that represents an object-like map `ObjectMap`. ```ts // `ObjectMap` const numberMapType = types.objectMap(types.number) ``` ### `types.arraySet(valuesType)` A type that represents an array-backed set `ArraySet`. ```ts // `ArraySet` const numberSetType = types.arraySet(types.number) ``` ### `types.refinement(baseType, checkFn: (data) => boolean | TypeCheckError | null)` A refinement over a given type. This allows you to do extra checks over models, ensure numbers are integers, etc. ```ts const integerType = types.refinement( types.number, (n) => { return Number.isInteger(n) }, "integer" ) const sumModelType = types.refinement(types.model(Sum), (sum) => { // imagine that for some reason `sum` includes a number `a`, a number `b` // and the result `result` const rightResult = sum.a + sum.b === sum.result // simple mode that will just return that the whole model is incorrect return rightResult // this will return that the result field is wrong return rightResult ? null : new TypeCheckError(["result"], "a+b", sum.result) }) ``` ### `types.tag(baseType, tag: T, typeName?: string)` Wraps a given type with tag information. This allows you to associate arbitrary metadata with the type of a prop that you can then use at runtime against instances. ```ts const widthType = types.tag( types.number, { displayName: "Width in inches", required: true }, "dimension" ) const heightType = types.tag( types.number, { displayName: "Height in inches", required: true }, "dimension" ) @model("MyModel") class MyModel extends Model({ width: tProp(widthType, 10), height: tProp(heightType, 10), }) {} const m = new MyModel({}) const type = types.model(m.constructor) const modelTypeInfo = getTypeInfo(type) as ModelTypeInfo const propTypeInfo = modelTypeInfo.props.width.typeInfo as TagTypeInfo<{ displayName: string }> const displayName = propTypeInfo.tag.displayName ``` ### Syntactic sugar for optional primitives with a default value You can also do `tProp(defaultValue: string | number | boolean)`, which is equivalent to `tProp(types.string|number|boolean, defaultValue)`. In other words, if you use `tProp(42)`, then the property will be a number and take the default value `42` when the value on the snapshot / model creation data is `undefined`. ### `TypeToData` It is also possible to get the type represented by a type via `TypeToData`: ```ts const t = types.object(() => { x: types.number, y: types.number }) // TypeToData = // { // x: number, // y: number // } ``` ### Reflection of runtime type info Thanks to `getTypeInfo(type: AnyType): TypeInfo` it is possible to get the runtime info of a type. `TypeInfo` is the base class for the following classes: - `LiteralTypeInfo` (`types.literal`, `types.undefined`, `types.null`) - `BooleanTypeInfo` (`types.boolean`) - `NumberTypeInfo` (`types.number`) - `StringTypeInfo` (`types.string`) - `OrTypeInfo` (`types.or`, `types.maybe`, `types.maybeNull`, `types.enum`) - `ArrayTypeInfo` (`types.array`) - `ModelTypeInfo` (`types.model`) - `ObjectTypeInfo` (`types.object`) - `RefinementTypeInfo` (`types.refinement`, `types.integer`, `types.nonEmptyString`) - `ObjectMapTypeInfo` (`types.objectMap`) - `ArraySetTypeInfo` (`types.arraySet`) - `RecordTypeInfo` (`types.record`) - `UncheckedTypeInfo` (`types.unchecked`) - `RefTypeInfo` (`types.ref`) - `FrozenTypeInfo` (`types.frozen`) ## Notes for `mobx-state-tree` users - Type checking in `mobx-keystone` is performed over instances once they have been created, not over snapshots, so the type definitions should be based on that fact. - There is no `types.optional` since setting default values is already covered by the `tProp` default values. - While models will automatically type-check themselves upon changes, other types will be only type-checked when they get attached to nodes. If for some reason you need to type-check them before then manually use the `typeCheck` method. --- # drafts ## Overview Sometimes it is useful to get hold of a copy of part of the state in order to edit it (while still having the original part of the state unmodified) and only commit changes to the original state once some bulk editing is finished. For example, say that your state contains a preferences section that can be edited in a form, yet you want to be able to commit/reset changes to this form to the original state in a single operation. `draft` allows exactly this. To create a draft all that is needed is to do this: ```ts const myDraftObject = draft(originalObject) ``` The `draft` function generates an instance with the following properties / methods: ### `data: T` Draft data object (a copy of `myRootStore.preferences` in this case). ### `originalData: T` Original data object (`myRootStore.preferences`). ### `commit(): void` Commits current draft changes to the original object. ### `commitByPath(path: Path): void` Partially commits current draft changes to the original object. If the path cannot be resolved in either the draft or the original object it will throw. Note that model IDs are checked to be the same when resolving the paths. ### `reset(): void` Resets the draft to be an exact copy of the current state of the original object. ### `resetByPath(path: Path): void` Partially resets current draft changes to be the same as the original object. If the path cannot be resolved in either the draft or the original object it will throw. Note that model IDs are checked to be the same when resolving the paths. ### `isDirty: boolean` Returns `true` if the draft has changed compared to the original object, `false` otherwise. ### `isDirtyByPath(path: Path): boolean` Returns `true` if the value at the given path of the draft has changed compared to the original object. If the path cannot be resolved in the draft it will throw. If the path cannot be resolved in the original object it will return `true`. Note that model IDs are checked to be the same when resolving the paths. ## Example Given the preferences example mentioned above, let's imagine we have a model (part of our whole app state) such as this one: ```ts @model("myApp/Preferences") class Preferences extends Model({ username: prop().withSetter(), avatarUrl: prop().withSetter(), }) { // just as an example, some validation code @computed get usernameValidationError(): string | null { // ... } @computed get avatarUrlValidationError(): string | null { // ... } @computed get hasValidationErrors() { return !!(this.usernameValidationError || this.avatarUrlValidationError) } } ``` Also let's imagine that we want a form that allows the user to change some preferences, and we want this form to have a "Save" and a "Reset" button. This is, we don't want any changes to the form to affect the app store preferences directly. To achieve this, first we will create a draft copy of the preferences: ```ts const preferencesDraft = draft(myRootStore.preferences) ``` We could then pass the data and the actions as separate properties to our own form component: ```tsx preferencesDraft.commit()} onReset={() => preferencesDraft.reset()} showValidationErrors={preferencesDraft.isDirty} saveDisabled={!preferencesDraft.isDirty || preferencesDraft.data.hasValidationErrors} resetDisabled={!preferencesDraft.isDirty} /> ``` Alternatively we could pass the draft itself and let the component use the draft properties / methods internally: ```tsx ``` --- # sandboxes ## Overview The sandbox utility allows you to make changes to a copy of an original subtree without actually changing the original state that drives UI rendering. Changes made in the sandbox can be committed to the original subtree or rejected. A common use case is testing of "what-if" scenarios. For example, consider this simple model: ```ts @model("MyApp/EvenNumber") class EvenNumber extends Model({ value: prop().withSetter(), }) { @computed get isValid(): number { return value % 2 === 0 } } ``` We can create a sandbox for an instance of this model: ```ts const num = new EvenNumber({ value: 0 }) const numSandbox = sandbox(num) ``` The sandbox manager `numSandbox` can now be used to test assigning a new value by performing the `setValue` action on a copy of `num` and validating the result using the computed property `isValid`. ```ts const isValid = numSandbox.withSandbox([num], (numCopy) => { numCopy.setValue(2) return { commit: false, return: numCopy.isValid } }) ``` The callback passed to `withSandbox` supports two return types: - `boolean` - When `true` any changes made to the sandbox copy are applied to the original subtree. When `false` any changes made to the copy are rejected, i.e. rolled back. - `{ commit: boolean; return: R }` - The `commit` property is equivalent to the boolean return value described above. The value of the `return` property is also returned by `withSandbox`. `withSandbox` can be called with a tuple of nodes in order to retrieve sandbox copies of multiple nodes at the same time: ```ts @model("NumberStore") class NumberStore extends Model({ a: prop(), b: prop(), }) {} const store = new NumberStore({ a: new EvenNumber({ value: 0 }), b: new EvenNumber({ value: 2 }), }) const storeSandbox = sandbox(store) storeSandbox.withSandbox([store.a, store.b], (a, b) => { // ... }) ``` `withSandbox` calls can be nested: ```ts const isValid = numSandbox.withSandbox([num], (numCopy1) => { numCopy1.setValue(2) const isValid1 = numCopy1.isValid const isValid2 = numSandbox.withSandbox([numCopy1], (numCopy2) => { numCopy2.setValue(numCopy2.value * 2) return { commit: false, return: numCopy2.isValid } }) return { commit: false, return: isValid1 && isValid2 } }) ``` When nesting `withSandbox` calls, the node for which the corresponding sandbox node is obtained must be a sandbox node itself, e.g.: ```ts // good: numSandbox.withSandbox([num], (numCopy1) => { numSandbox.withSandbox([numCopy1], (numCopy2) => { // ... }) // ... }) // bad: numSandbox.withSandbox([num], (numCopy1) => { numSandbox.withSandbox([num], (numCopy2) => { // ... }) // ... }) ``` When changes made in nested `withSandbox` calls are to be committed, only the outermost `withSandbox` call commits changes to the original subtree. Changes made in any inner `withSandbox` call are either retained or rolled back depending on the commit flag. E.g.: ```ts numSandbox.withSandbox([num], (numCopy1) => { // `numCopy1.value` => 0 numCopy1.setValue(1) // `numCopy1.value` => 1 numSandbox.withSandbox([numCopy1], (numCopy2) => { // `numCopy2.value` => 1 numCopy2.setValue(2) // `numCopy2.value` => 2 numSandbox.withSandbox([numCopy2], (numCopy3) => { // `numCopy3.value` => 2 numCopy3.setValue(3) // `numCopy3.value` => 3 return true }) // `numCopy2.value` => 3 return false }) // `numCopy1.value` => 1 return true }) // `num.value` => 1 ``` The sandbox copy of a subtree root node tracks the [root store](./rootStores.mdx) state of the original subtree root node, i.e. when the original subtree root node is registered as a root store, its corresponding sandbox copy becomes a root store as well and vice versa. The `sandbox` function generates an instance with the following methods: - `withSandbox(nodes: T, fn: (...nodes: T) => boolean | { commit: boolean; return: R }): R` - Executes `fn` with sandbox copies of the elements of `nodes`. Any changes made to the sandbox are applied to the original subtree when `fn` returns `true` or `{ commit: true, ... }`. When `fn` returns `false` or `{ commit: false, ... }` the changes made to the sandbox are rejected. When `fn` returns an object of type `{ commit: boolean; return: R }` then `withSandbox` returns a value of type `R`. - `dispose()` - Disposes of the sandbox. ### Checking if a node is sandboxed / getting a node sandbox manager Sometimes it might be useful to know if a node is part of a sandbox or its related sandbox manager. To do so you can use the following functions: - `isSandboxedNode(node: object): boolean` - Returns if a given node is a sandboxed node. - `getNodeSandboxManager(node: object): SandboxManager | undefined` - Returns the sandbox manager of a node, or `undefined` if none. These might be useful for example to filter out reactions that should only run on the "real" nodes, but not on the sandboxed ones. ## Examples ### Store of polymorphic items Consider a store of polymorphic items which can generally co-exist in the same store, but each item type implements validation rules that determine whether the item is valid in the context of the other items currently present in the store. Let all items implement the following interface: ```ts interface Item { error: string | undefined } ``` Further, let the item store be a model which contains ... - an array of items currently present in the store, - a computed property which returns an array of errors accumulated from all items in the store, - a method (action) to add a new item, and - a method that assesses whether a new item can be added to the store without error. ```ts const sandboxCtx = createContext() @model("MyApp/ItemStore") class ItemStore extends Model({ items: prop(() => []), }) { @computed get errors(): string[] { return this.items.map((item) => item.error).filter((error) => error !== undefined) as string[] } @modelAction addItem(item: Item): void { this.items.push(item) } canAddItem(item: Item): boolean { return !!sandboxCtx.get(this)?.withSandbox([this], (node) => { node.addItem(item) return { commit: false, return: node.errors.length === 0 } }) } } ``` `canAddItem` requires access to the sandbox manager which is provided using a [context](./contexts.mdx). ```ts const store = new ItemStore({}) const storeSandbox = sandbox(store) sandboxCtx.setComputed(store, () => storeSandbox) ``` Now, consider the following item model which, in this example, may only exist once per item store: ```ts @model("MyApp/ItemA") class ItemA extends Model({}) implements Item { @computed get error(): string | undefined { return getParent(this)?.some((item) => item !== this && item instanceof ItemA) ? "only 1 instance of ItemA allowed" : undefined } } ``` When the store does not yet contain an item of type `ItemA`, `canAddItem` returns `true` when called with an instance of `ItemA` and, thus, this item can be added to the item store without error: ```ts const item1 = new ItemA({}) console.log(store.canAddItem(item1)) // => `true` store.addItem(item1) console.log(store.errors) // => `[]` ``` However, adding a second item of type `ItemA` would incur an error: ```ts const item2 = new ItemA({}) console.log(store.canAddItem(item2)) // => `["only 1 instance of ItemA allowed"]` ``` --- # computedTrees ## Overview Computed trees are useful for transforming one state tree into another, e.g. in order to reactively derive an alternative view of an original tree that is attached as a property to the original tree and, thus, supports tree traversal functions, contexts, references, etc. (see the ["Properties of computed trees"](#properties-of-computed-trees) section below for more details). To create a computed tree, decorate a `get` accessor of a class or data model with the `@computedTree` decorator: ```ts @model("myApp/M") class M extends Model({ id: idProp, // ... }) { @computedTree get view() { return new V({ // compute a stable/deterministic ID id: `${this.id}.view`, // ... }) } } @model("myApp/V") class V extends Model({ id: idProp, // ... }) { // ... } ``` To check whether a node is a regular or computed tree node, use the `isComputedTreeNode(node: object): boolean` utility function. :::note The behavior of a computed tree property differs from a MobX computed property. A computed tree property evaluates eagerly (i.e. a computed tree is immediately attached upon model instantiation), cached also without being observed in a reactive context and not suspended when not observed. In contrast, a MobX computed property evaluates lazily, is cached only when observed in a reactive context and (by default) suspends when not observed. ::: ## Properties of computed trees Computed trees have the following properties: - **Immutability** because a computed tree is _derived_ from another (mutable or computed) tree or observable value. Immutability is enforced at runtime by means of the [readonly middleware](./actionMiddlewares/readonlyMiddleware.mdx). - **Action middlewares** are never applied to a computed tree because of its immutability. - **Contexts** are available within a computed tree, across computed trees, and across the boundary between a regular and a computed tree. - **Tree traversal methods** can be used on computed tree nodes within a computed tree, across computed trees, and across the boundary between a regular and a computed tree. However, most [utility methods](./treeLikeStructure.mdx#utility-methods) do not work on computed tree nodes because of their immutability except for `onChildAttachedTo` whose listener callback gets called upon re-computation of a computed tree child. - **References** are available within a computed tree, across computed trees, and across the boundary between a regular and a computed tree. When referencing a model instance in a computed tree, it is important that the ID of the referenced model instance is _stable_ across re-computations of the computed tree. - **Back-references** are available within a computed tree, across computed trees, and across the boundary between a regular and a computed tree. - **Life-cycle event hooks** are available and work as expected. `onAttachedToRootStore` is called upon each re-computation of the computed tree when it is part of a root store tree. - **Snapshots** do not contain data of computed trees. - **Patches** are not generated for computed tree nodes because of immutability. --- # integrations/reduxCompatibility ## `asReduxStore` It is possible to transform a `mobx-keystone` tree node into a Redux compatible store. ```ts const todoListReduxStore = asReduxStore(todoList) // or with Redux middlewares const todoListReduxStore = asReduxStore(todoList, middleware1, middleware2) ``` Such store will have most of the usual Redux store methods: - `getState()` is a thin wrapper over `getSnapshot(storeTarget)`. - `dispatch(action)` accepts an action in the form `{ type: "applyAction"; payload: ActionCall }`, which can be constructed by using `actionCallToReduxAction(actionCall)` and will call `applyAction` with the store target and the action call from the payload. - `subscribe(listener)` will use `onSnapshot(storeTarget, listener)` and return a disposer. ## `connectReduxDevTools` It is also possible to connect a store to some Redux DevTools monitor thanks to the `connectReduxDevTools` function and the `remotedev` package. ```ts // or const remotedev = require("remotedev") // create a connection to the monitor (for example with `connectViaExtension`) const connection = remotedev.connectViaExtension({ name: "my cool store", }) connectReduxDevTools(remotedev, connection, todoList) ``` This function also accepts an optional options object with the following properties: - `logArgsNearName` - if it should show the arguments near the action name (default is `true`). If you want to see it in action feel free to check the [Todo List Example](../examples/todoList/todoList.mdx), open the Redux DevTools and perform some actions. --- # integrations/yjsBinding The [`mobx-keystone-yjs`](https://www.npmjs.com/package/mobx-keystone-yjs) package ensures a `Y.js` document is kept in sync with a `mobx-keystone` store and vice-versa. This is useful for example when you when you want to have multiple clients that keep in sync with each other using a peer-to-peer connection, an intermediate server, etc. Another nice feature of CRDTs is that they are conflict-free, so you don't have to worry about conflicts when two clients edit the same data at the same time, even if they were offline while doing such changes. Due to this all updates become optimistic updates and perceived performance is great since it does not require a server confirming the operation. ## Binding `Y.js` data to a model instance ```ts const { // The bound mobx-keystone instance. boundObject, // Disposes the binding. dispose, // The Y.js origin symbol used for binding transactions. yjsOrigin, } = bindYjsToMobxKeystone({ // The mobx-keystone model type. mobxKeystoneType, // The Y.js document. yjsDoc, // The bound Y.js data structure. yjsObject, }) ``` Note that the `yjsObject` must be a `Y.Map`, `Y.Array` or a `Y.Text` and its structure must be compatible with the provided `mobx-keystone` type (or, to be more precise, its snapshot form). ## First migration - converting JSON to `Y.js` data If you already have a model instance snapshot stored somewhere and want to start binding it to a `Y.js` data structure you can use the `convertJsonToYjsData` function to make this first migration. `convertJsonToYjsData` takes a single argument, a JSON value (usually a snapshot of the model you want to bind) and returns a `Y.js` data structure (`Y.Map`, `Y.Array`, etc.) ready to be bound to that model. Frozen values are a special case and they are kept as immutable plain values. ## Using Y.Text as a model node The special model `YjsTextModel` can be used to bind a `Y.Text` to a `mobx-keystone` model. ```ts const text = new YjsTextModel(); const boundModel.setText(text); text.yjsText.insert(0, 'Hello world!'); ``` Note that `yjsText` will throw an error if you try to access while it is not part of a bounded tree. This is due to a limitation of `Y.js` itself, since it allows limited manipulation of types while they are outside a `Y.Doc` tree. ## The `YjsBindingContext` All nodes inside a bound tree have access to a `YjsBindingContext` instance. The instance can be accessed using: ```ts yjsBindingContext.get(nodePartOfTheBoundTree) ``` And this instance provides access to the following data: - `yjsDoc`: The `Y.js` document. - `yjsObject`: The bound `Y.js` data structure. - `mobxKeystoneType`: The `mobx-keystone` model type. - `yjsOrigin`: The origin symbol used for transactions. - `boundObject`: The bound `mobx-keystone` instance. - `isApplyingYjsChangesToMobxKeystone`: Whether we are currently applying `Y.js` changes to the `mobx-keystone` model. ## Example A full example is available [here](../examples/yjsBinding/yjsBinding.mdx). --- # integrations/loroBinding The [`mobx-keystone-loro`](https://www.npmjs.com/package/mobx-keystone-loro) package ensures a [`Loro`](https://loro.dev/) document is kept in sync with a `mobx-keystone` store and vice-versa. This is useful for example when you when you want to have multiple clients that keep in sync with each other using a peer-to-peer connection, an intermediate server, etc. Another nice feature of CRDTs is that they are conflict-free, so you don't have to worry about conflicts when two clients edit the same data at the same time, even if they were offline while doing such changes. Due to this all updates become optimistic updates and perceived performance is great since it does not require a server confirming the operation. ## Binding `Loro` data to a model instance ```ts const { // The bound mobx-keystone instance. boundObject, // Disposes the binding. dispose, // The Loro origin string used for binding transactions. loroOrigin, } = bindLoroToMobxKeystone({ // The mobx-keystone model type. mobxKeystoneType, // The Loro document. loroDoc, // The bound Loro data structure. loroObject, }) ``` Note that the `loroObject` must be a `LoroMap`, `LoroMovableList` or a `LoroText` and its structure must be compatible with the provided `mobx-keystone` type (or, to be more precise, its snapshot form). ## First migration - converting JSON to `Loro` data If you already have a model instance snapshot stored somewhere and want to start binding it to a `Loro` data structure you can use the `convertJsonToLoroData` function to make this first migration. `convertJsonToLoroData` takes a single argument, a JSON value (usually a snapshot of the model you want to bind) and returns a `Loro` data structure (`LoroMap`, `LoroMovableList`, etc.) ready to be bound to that model. Frozen values are a special case and they are kept as immutable plain values. ## Using LoroText as a model node The special model `LoroTextModel` can be used to bind a `LoroText` to a `mobx-keystone` model. ```ts const text = new LoroTextModel(); const boundModel.setText(text); text.loroText.insert(0, 'Hello world!'); ``` Note that `loroText` will return `undefined` if you try to access while it is not part of a bounded tree. ## Move Operations Unlike Y.js, Loro's `LoroMovableList` supports native move operations. To take advantage of this, use the `moveWithinArray` helper function: ```ts runUnprotected(() => { // Move item from index 0 to index 3 moveWithinArray(boundObject.items, 0, 3) }) ``` `moveWithinArray(array, fromIndex, toIndex)` moves an item within an array: - **fromIndex**: The current index of the item to move - **toIndex**: The target index (position before the move happens) When used on a mobx-keystone array bound to Loro, this translates to a native `loroList.move()` operation, preserving the identity and history of the moved item across all clients. For unbound arrays, it performs a standard splice-based move. ## The `LoroBindingContext` All nodes inside a bound tree have access to a `LoroBindingContext` instance. The instance can be accessed using: ```ts loroBindingContext.get(nodePartOfTheBoundTree) ``` And this instance provides access to the following data: - `loroDoc`: The `Loro` document. - `loroObject`: The bound `Loro` data structure. - `mobxKeystoneType`: The `mobx-keystone` model type. - `loroOrigin`: The origin string used for transactions. - `boundObject`: The bound `mobx-keystone` instance. - `isApplyingLoroChangesToMobxKeystone`: Whether we are currently applying `Loro` changes to the `mobx-keystone` model. ## Example A full example is available [here](../examples/loroBinding/loroBinding.mdx). --- # examples/todoList/todoList ## Code ### `store.ts` ```ts import { computed } from "mobx" import { connectReduxDevTools, idProp, model, Model, modelAction, ModelAutoTypeCheckingMode, registerRootStore, setGlobalConfig, tProp, types, } from "mobx-keystone" // for this example we will enable runtime data checking even in production mode setGlobalConfig({ modelAutoTypeChecking: ModelAutoTypeCheckingMode.AlwaysOn, }) // 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("todoSample/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 use runtime type checking, id: idProp, // an optional string that will use a random id when not provided text: tProp(types.string), // a required string done: tProp(types.boolean, false), // an optional boolean that will default to false // if we didn't require runtime type checking we could do this // id: idProp, // text: prop(), // done: prop(false) }) { // 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 } } @model("todoSample/TodoList") export class TodoList extends Model({ // in this case the default uses an arrow function to create the object since it is not a primitive // and we need a different array for each model instane todos: tProp(types.array(types.model(Todo)), () => []), // if we didn't require runtime type checking // todos: prop(() => []) }) { // standard mobx decorators (such as computed) can be used as usual, since props are observables @computed get pending() { return this.todos.filter((t) => !t.done) } @computed get done() { return this.todos.filter((t) => t.done) } @modelAction add(todo: Todo) { this.todos.push(todo) } @modelAction remove(todo: Todo) { const index = this.todos.indexOf(todo) if (index >= 0) { this.todos.splice(index, 1) } } } export function createDefaultTodoList(): TodoList { // the parameter is the initial data for the model return new TodoList({ todos: [ new Todo({ text: "make mobx-keystone awesome!" }), new Todo({ text: "spread the word" }), new Todo({ text: "buy some milk", done: true }), ], }) } export function createRootStore(): TodoList { const rootStore = createDefaultTodoList() // although not strictly required, it is always a good idea to register your root stores // as such, since this allows the model hook `onAttachedToRootStore` to work and other goodies registerRootStore(rootStore) // we can also connect the store to the redux dev tools const remotedev = require("remotedev") const connection = remotedev.connectViaExtension({ name: "Todo List Example", }) connectReduxDevTools(remotedev, connection, rootStore) return rootStore } ``` ### `app.tsx` ```tsx import { observer } from "mobx-react" import { useState } from "react" import { LogsView } from "./logs" import { createRootStore, Todo, TodoList } from "./store" // we use mobx-react to connect to the data, as it is usual in mobx // this library is framework agnostic, so it can work anywhere mobx can work // (even outside of a UI) export const App = observer(() => { const [rootStore] = useState(() => createRootStore()) return ( <>
) }) export const TodoListView = observer(({ list }: { list: TodoList }) => { const [newTodo, setNewTodo] = useState("") const renderTodo = (todo: Todo) => ( { todo.setDone(!todo.done) }} onRemove={() => { list.remove(todo) }} /> ) return (
{list.pending.length > 0 && ( <>
TODO
{list.pending.map((t) => renderTodo(t))} )} {list.done.length > 0 && ( <>
DONE
{list.done.map((t) => renderTodo(t))} )}
{ setNewTodo(ev.target.value || "") }} placeholder="I will..." />
) }) function TodoView({ done, text, onClick, onRemove, }: { done: boolean text: string onClick: () => void onRemove: () => void }) { return (
{done ? "✔️" : "👀"} {text} {}
) } ``` ### `logs.tsx` ```tsx import { ActionCall, getSnapshot, onActionMiddleware, onPatches, Patch } from "mobx-keystone" import { observer, useLocalObservable } from "mobx-react" import React, { useEffect } from "react" import { TodoList } from "./store" // this is just for the client/server demo export const cancelledActionSymbol = Symbol("cancelledAction") interface ExtendedActionCall extends ActionCall { cancelled: boolean } export const LogsView = observer((props: { rootStore: TodoList }) => { const data = useLocalObservable(() => ({ actions: [] as ExtendedActionCall[], patchesList: [] as Patch[][], addAction(actionCall: ExtendedActionCall) { this.actions.push(actionCall) }, addPatches(patches: Patch[]) { this.patchesList.push(patches) }, })) useEffect(() => { // we can use action middlewares for several things // in this case we will keep a log of the actions done over the todo list const disposer = onActionMiddleware(props.rootStore, { onFinish(actionCall, ctx) { const extendedActionCall: ExtendedActionCall = { ...actionCall, cancelled: !!ctx.data[cancelledActionSymbol], } data.addAction(extendedActionCall) }, }) return disposer }, [data, props.rootStore]) useEffect(() => { // also it is possible to get a list of changes in the form of patches, // even with inverse patches to undo the changes const disposer = onPatches(props.rootStore, (patches, _inversePatches) => { data.addPatches(patches) }) return disposer }) // we can convert any model (or part of it) into a plain JS structure // with it we can: // - serialize to later deserialize it with `fromSnapshot` // - pass it to non mobx-friendly components // snapshots respect immutability, so if a subobject is changed // its refrence will be kept const rootStoreSnapshot = getSnapshot(props.rootStore) return ( <> {data.actions.map((action, index) => ( ))} {data.patchesList.map(patchesToText)} {JSON.stringify(rootStoreSnapshot, null, 2)} ) }) function ActionCallToText(props: { actionCall: ExtendedActionCall }) { const actionCall = props.actionCall const args = actionCall.args.map((arg) => JSON.stringify(arg)).join(", ") const path = actionCall.targetPath.join("/") const text = `[${path}] ${actionCall.actionName}(${args})` if (actionCall.cancelled) { return ( <> {text}{" "} (cancelled and sent to server)

) } return ( <> {text}

) } function patchToText(patch: Patch) { const path = patch.path.join("/") let str = `[${path}] ${patch.op}` if (patch.op !== "remove") { str += " -> " + JSON.stringify(patch.value) } return str + "\n" } function patchesToText(patches: Patch[]) { return patches.map(patchToText) } function PreSection(props: { title: string; children: React.ReactNode }) { return ( <>
{props.title}
{props.children}
) } ``` --- # examples/clientServer/clientServer In this example we will be synchronizing two separate root stores via action capturing and applying, which will simulate how to instances of an app talk with a server to keep in sync. We will use pessimistic updates, this is, we will cancel the local action and then actually run the action when the server tells the client to do so. Note: This example has an artificial delay to simulate network latency. ## Code ### `server.ts` ```ts import { applySerializedActionAndTrackNewModelIds, getSnapshot, SerializedActionCall, SerializedActionCallWithModelIdOverrides, } from "mobx-keystone" import { createRootStore } from "../todoList/store" type MsgListener = (actionCall: SerializedActionCallWithModelIdOverrides) => void class Server { private serverRootStore = createRootStore() private msgListeners: MsgListener[] = [] getInitialState() { return getSnapshot(this.serverRootStore) } onMessage(listener: MsgListener) { this.msgListeners.push(listener) } sendMessage(actionCall: SerializedActionCall) { // the timeouts are just to simulate network delays setTimeout(() => { // apply the action over the server root store // sometimes applying actions might fail (for example on invalid operations // such as when one client asks to delete a model from an array and other asks to mutate it) // so we try / catch it let serializedActionCallToReplicate: SerializedActionCallWithModelIdOverrides | undefined try { // we use this to apply the action on the server side and keep track of new model IDs being // generated, so the clients will have the chance to keep those in sync const applyActionResult = applySerializedActionAndTrackNewModelIds( this.serverRootStore, actionCall ) serializedActionCallToReplicate = applyActionResult.serializedActionCall } catch (err) { console.error("error applying action to server:", err) } if (serializedActionCallToReplicate) { setTimeout(() => { // and distribute message, which includes new model IDs to keep them in sync this.msgListeners.forEach((listener) => { listener(serializedActionCallToReplicate) }) }, 500) } }, 500) } } export const server = new Server() ``` ### `appInstance.tsx` ```tsx import { ActionTrackingResult, applySerializedActionAndSyncNewModelIds, fromSnapshot, onActionMiddleware, SerializedActionCallWithModelIdOverrides, serializeActionCall, } from "mobx-keystone" import { observer } from "mobx-react" import { useState } from "react" import { TodoListView } from "../todoList/app" import { cancelledActionSymbol, LogsView } from "../todoList/logs" import { TodoList } from "../todoList/store" import { server } from "./server" function initAppInstance() { // we get the snapshot from the server, which is a serializable object const rootStoreSnapshot = server.getInitialState() // and hydrate it into a proper object const rootStore = fromSnapshot(rootStoreSnapshot) let serverAction = false const runServerActionLocally = (actionCall: SerializedActionCallWithModelIdOverrides) => { const wasServerAction = serverAction serverAction = true try { // in clients we use the sync new model ids version to make sure that // any model ids that were generated in the server side end up being // the same in the client side applySerializedActionAndSyncNewModelIds(rootStore, actionCall) } finally { serverAction = wasServerAction } } // listen to action messages to be replicated into the local root store server.onMessage((actionCall) => { runServerActionLocally(actionCall) }) // also listen to local actions, cancel them and send them to the server onActionMiddleware(rootStore, { onStart(actionCall, ctx) { if (serverAction) { // just run the server action unmodified return undefined } else { // if the action does not come from the server cancel it silently // and send it to the server // it will then be replicated by the server and properly executed server.sendMessage(serializeActionCall(actionCall, rootStore)) ctx.data[cancelledActionSymbol] = true // just for logging purposes // "cancel" the action by returning undefined return { result: ActionTrackingResult.Return, value: undefined, } } }, }) return rootStore } export const AppInstance = observer(() => { const [rootStore] = useState(() => initAppInstance()) return ( <>
) }) ``` ### `app.tsx` ```tsx import { observer } from "mobx-react" import { AppInstance } from "./appInstance" // we will expose both app instances in the ui export const App = observer(() => { return (

App Instance #1

App Instance #2

) }) ``` --- # examples/yjsBinding/yjsBinding In this example we will be synchronizing two separate root stores via the `mobx-keystone-yjs` package (documentation [here](../../integrations/yjsBinding.mdx)). This package ensures a `Y.js` document is kept in sync with a `mobx-keystone` store and vice-versa. This is useful for example when you when you want to have multiple clients that keep in sync with each other using a peer-to-peer connection, an intermediate server, etc. Another nice feature of CRDTs is that they are conflict-free, so you don't have to worry about conflicts when two clients edit the same data at the same time, even if they were offline while doing such changes. Due to this all updates become optimistic updates and perceived performance is great since it does not require a server confirming the operation. Note: This example uses `y-webrtc` to share state using WebRTC (P2P). ## Code ### `appInstance.tsx` ```tsx import { observable } from "mobx" import { getSnapshot, registerRootStore } from "mobx-keystone" import { applyJsonObjectToYMap, bindYjsToMobxKeystone } from "mobx-keystone-yjs" import { observer } from "mobx-react" import { useState } from "react" import { WebrtcProvider } from "y-webrtc" import * as Y from "yjs" import { TodoListView } from "../todoList/app" import { createDefaultTodoList, TodoList } from "../todoList/store" function getInitialState() { // here we just generate what a client would have saved from a previous session, // but it could be stored in each client local storage or something like that const yjsDoc = new Y.Doc() const yjsRootStore = yjsDoc.getMap("rootStore") const todoListSnapshot = getSnapshot(createDefaultTodoList()) applyJsonObjectToYMap(yjsRootStore, todoListSnapshot) const updateV2 = Y.encodeStateAsUpdateV2(yjsDoc) yjsDoc.destroy() return updateV2 } function initAppInstance() { // we get the initial state from the server, which is a Yjs update const rootStoreYjsUpdate = getInitialState() // hydrate into a Yjs document const yjsDoc = new Y.Doc() Y.applyUpdateV2(yjsDoc, rootStoreYjsUpdate) // and create a binding into a mobx-keystone object rootStore const { boundObject: rootStore } = bindYjsToMobxKeystone({ mobxKeystoneType: TodoList, yjsDoc, yjsObject: yjsDoc.getMap("rootStore"), }) // although not strictly required, it is always a good idea to register your root stores // as such, since this allows the model hook `onAttachedToRootStore` to work and other goodies registerRootStore(rootStore) // connect to other peers via webrtc const webrtcProvider = new WebrtcProvider("mobx-keystone-yjs-binding-demo", yjsDoc) // expose the webrtc connection status as an observable const status = observable({ connected: webrtcProvider.connected, setConnected(value: boolean) { this.connected = value }, }) webrtcProvider.on("status", (event) => { status.setConnected(event.connected) }) const toggleWebrtcProviderConnection = () => { if (webrtcProvider.connected) { webrtcProvider.disconnect() } else { webrtcProvider.connect() } } return { rootStore, status, toggleWebrtcProviderConnection } } export const AppInstance = observer(() => { const [{ rootStore, status, toggleWebrtcProviderConnection }] = useState(() => initAppInstance()) return ( <>
{status.connected ? "Online (sync enabled)" : "Offline (sync disabled)"}
) }) ``` ### `app.tsx` ```tsx import { AppInstance } from "./appInstance" let iframeResizerChildInited = false function initIframeResizerChild() { if (!iframeResizerChildInited) { iframeResizerChildInited = true void import("@iframe-resizer/child") } } export const App = () => { initIframeResizerChild() return ( <>

App Instance

) } ``` --- # examples/loroBinding/loroBinding In this example we will be synchronizing two separate root stores via the `mobx-keystone-loro` package (documentation [here](../../integrations/loroBinding.mdx)). This package ensures a `Loro` document is kept in sync with a `mobx-keystone` store and vice-versa. This is useful for example when you when you want to have multiple clients that keep in sync with each other using a peer-to-peer connection, an intermediate server, etc. Another nice feature of CRDTs is that they are conflict-free, so you don't have to worry about conflicts when two clients edit the same data at the same time, even if they were offline while doing such changes. Due to this all updates become optimistic updates and perceived performance is great since it does not require a server confirming the operation. Note: This example uses `BroadcastChannel` to share state between the two instances in the same browser. ## Code ### `appInstance.tsx` ```tsx import { LoroDoc } from "loro-crdt" import { observable } from "mobx" import { getSnapshot, registerRootStore } from "mobx-keystone" import { applyJsonObjectToLoroMap, bindLoroToMobxKeystone } from "mobx-keystone-loro" import { observer } from "mobx-react" import { useEffect, useState } from "react" import { TodoListView } from "../todoList/app" import { createDefaultTodoList, TodoList } from "../todoList/store" function getInitialState() { const doc = new LoroDoc() const loroRootStore = doc.getMap("rootStore") const todoListSnapshot = getSnapshot(createDefaultTodoList()) applyJsonObjectToLoroMap(loroRootStore, todoListSnapshot) doc.commit() const update = doc.export({ mode: "snapshot" }) return update } // we get the initial state from the server, which is a Loro update const rootStoreLoroUpdate = getInitialState() function initAppInstance() { // hydrate into a Loro document const doc = new LoroDoc() doc.import(rootStoreLoroUpdate) // and create a binding into a mobx-keystone object rootStore const { boundObject: rootStore } = bindLoroToMobxKeystone({ mobxKeystoneType: TodoList, loroDoc: doc, loroObject: doc.getMap("rootStore"), }) // although not strictly required, it is always a good idea to register your root stores // as such, since this allows the model hook `onAttachedToRootStore` to work and other goodies registerRootStore(rootStore) // expose the connection status as an observable const status = observable({ connected: true, setConnected(value: boolean) { this.connected = value }, }) // Simple sync via BroadcastChannel const channel = new BroadcastChannel("mobx-keystone-loro-binding-demo") let lastSentVersion = doc.version() const unsubLocalUpdates = doc.subscribeLocalUpdates((update) => { if (status.connected) { channel.postMessage({ update, fromPeer: doc.peerIdStr }) lastSentVersion = doc.version() } }) channel.onmessage = (event) => { if (!status.connected) return const { update, fromPeer, isSyncRequest } = event.data if (fromPeer === doc.peerIdStr) return // ignore own messages if any try { doc.import(update) lastSentVersion = doc.version() if (isSyncRequest) { // if it is a sync request, send back only the changes they are missing channel.postMessage({ update: doc.export({ mode: "update", from: event.data.version }), fromPeer: doc.peerIdStr, isSyncRequest: false, }) } } catch (e) { console.error("Import failed", e) } } const toggleConnection = () => { const newConnected = !status.connected status.setConnected(newConnected) if (newConnected) { // when reconnecting, send our current state to others and ask for theirs channel.postMessage({ update: doc.export({ mode: "update", from: lastSentVersion }), version: doc.version(), fromPeer: doc.peerIdStr, isSyncRequest: true, }) // checkpoint the version we just sent lastSentVersion = doc.version() } } return { rootStore, status, toggleConnection, dispose: () => unsubLocalUpdates() } } export const AppInstance = observer(() => { const [{ rootStore, status, toggleConnection, dispose }] = useState(() => initAppInstance()) useEffect(() => { return () => { dispose() } }, [dispose]) return ( <>
{status.connected ? "Online (sync enabled)" : "Offline (sync disabled)"}
) }) ``` ### `app.tsx` ```tsx import React from "react" import { AppInstance } from "./appInstance" let iframeResizerChildInited = false function initIframeResizerChild() { if (!iframeResizerChildInited) { iframeResizerChildInited = true void import("@iframe-resizer/child") } } export const App = () => { initIframeResizerChild() return (
) } ```