# mobx-keystone # intro `mobx-keystone` helps you build complex client-side apps with a single source of truth, mutable model code, and immutable traceability built in. You write straightforward actions and computed values, while the library gives you snapshots, patches, undo/redo, and runtime protection on top. You can think of it as a TypeScript-first model layer on top of MobX that scales better as your domain grows. - Start here: [Installation](./installation.mdx) - Learn by building: [Class Models](./classModels.mdx) - See a full example: [Todo List Example](./examples/todoList/todoList.mdx) - Migrating from MST: [Migration Guide](./mstMigrationGuide.mdx) - API reference: [API docs](/api/) [![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) ## Quick glance ```ts @model("todo/Todo") class Todo extends Model({ text: prop(""), done: prop(false), }) { @modelAction toggle() { this.done = !this.done } } @model("todo/Store") class TodoStore extends Model({ todos: prop(() => []), }) { @computed get pendingCount() { return this.todos.filter((t) => !t.done).length } @modelAction addTodo(text: string) { this.todos.push(new Todo({ text })) } } const store = new TodoStore({}) registerRootStore(store) ``` ## Why teams choose mobx-keystone - Mutable action code with protected updates, so state changes stay explicit and safe. - Runtime snapshots and JSON patches for persistence, sync, replay, and debugging. - Built-in primitives for references, transactions, action middlewares, and undo/redo. - Strong TypeScript inference for models, snapshots, and actions. - Composable domain models that stay maintainable as app complexity grows. - Seamless integration with MobX and `mobx-react`. ## What users are saying > "I've never been so in love with a tool. [...] In my eyes it is the perfect state management tool for TS/React projects. [...] Building complex clientside apps has never been so easy and fun for me." > > - [@finallyblueskies](https://github.com/finallyblueskies), [#538](https://github.com/xaviergonz/mobx-keystone/issues/538) > "I'm absolutely loving this project. [...] It's taken all the best bits from mobx and mobx-state-tree and put them into a single package that's a joy to work with. [...] You've literally thought of everything!" > > - [@robclouth](https://github.com/robclouth), [#146](https://github.com/xaviergonz/mobx-keystone/issues/146) > "Thank you for this amazing library. [...] After evaluating every state library out there, mobx-keystone stands out [...] a true single source of truth for records, first-class relationships [...] first-class reactivity." > > - [@lolmaus](https://github.com/lolmaus), [#566](https://github.com/xaviergonz/mobx-keystone/issues/566) > "First of all thank you for this amazing library, it really is a joy to work with." > > - [@exception-producer](https://github.com/exception-producer), [#559](https://github.com/xaviergonz/mobx-keystone/issues/559) > "mobx-keystone is awesome and way easier to use for typesafe domain modeling than MST." > > - [@krnsk0](https://github.com/krnsk0), [#467](https://github.com/xaviergonz/mobx-keystone/issues/467) ## How it works At the center of `mobx-keystone` is a _living tree_ of mutable but strictly protected models, arrays, and plain objects. You update state through model actions, and immutable structurally shared snapshots are derived automatically. This gives you mutability where it helps developer experience, plus immutable traceability where it helps reliability. Trees can only be modified by actions that belong to the same subtree. Actions are replayable and can be distributed, and fine-grained changes can be observed as JSON patches. Because `mobx-keystone` uses MobX behind the scenes, it integrates naturally with [`mobx`](https://mobx.js.org) and [`mobx-react`](https://github.com/mobxjs/mobx-react). The snapshot and middleware system also makes it possible to replace a Redux reducer/store pair with model-driven state and connect Redux devtools. `mobx-keystone` consists of composable _models_ that capture domain state and behavior together. Model instances are created from props, protect their own updates, and reconcile efficiently when applying snapshots. ## Resources for LLM workflows Need markdown exports for AI/LLM tools? See [llms.txt](/llms.txt), [llms-full.txt](/llms-full.txt), or the MD docs intro. --- # 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 When not using runtime type checking, `mobx-keystone` uses standard TypeScript type annotations to declare model data, which lowers 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. `withSetter` also accepts a value transform function. For example `withSetter(cloneTreeValue)` clones incoming non-primitive values before setting them, which is useful when the same object instance may be reused across different places in the tree. If you need clone options, wrap it (for example `withSetter((x) => cloneTreeValue(x, { generateNewIds: false }))`). Note that the transform function is only applied when using the generated setter method (for example, `model.setX(3)`); it is **not** applied during model construction (`new Model({ x: 3 })`) or snapshot deserialization, where the value is assigned as provided. ## 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({ title: prop("demo"), }) { // non-observable runtime data saveCount = 0 markSaved() { this.saveCount++ } // or observable in the usual MobX way @observable isSaving = false @action setSaving(isSaving: boolean) { this.isSaving = isSaving } } ``` ## 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 ends up in the snapshot representation of the model and allows `fromSnapshot` to reconstruct the correct model class, so 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`. ### Runtime model registration (`registerModels`) Most applications do not need to do anything special: importing and using model classes normally is enough for `@model(...)` decorators to register them. You may need manual registration when your app mainly deserializes snapshots and some model imports are only used as TypeScript types (or otherwise elided by your build pipeline). In that case, `fromSnapshot` may fail with: ```txt model with name "..." not found in the registry ``` To make registration explicit, call `registerModels(...)` once at app startup with runtime class references: ```ts registerModels(RootStore, User) ``` This does not instantiate models; it only verifies the classes are loaded and registered. ## 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({ customId: "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, }) ``` You can also define a generator per model ID field using `idProp.withGenerator(...)`: ```ts @model("myApp/Todo") class Todo extends Model({ id: idProp.withGenerator(() => `todo-${uuid()}`), text: prop(), }) {} ``` For `ExtendedModel`, the generator follows inheritance: - If base has one and extended does not override the id prop, extended uses base generator. - If extended overrides a base id prop, `withGenerator(...)` must match the base one exactly (including `undefined`). - If base has no id prop and extended defines one, extended uses its own generator. ## 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, meaning 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. ## Mixins If you want a constrained mixin-style API for class models you can use `defineModelMixin` and `composeMixins`. This avoids repeating `as unknown as ModelClass<...>` in each factory. Pass the model properties as the first argument; an optional builder as the last argument adds methods. Use `req()` as the second argument to declare that the base model must already expose a given shape before the mixin is applied. ```ts @model("myApp/Entity") class Entity extends Model({}) {} // Props only – no builder needed const countableMixin = defineModelMixin( { quantity: tProp(types.number, 0) } ) // Props + methods via builder const countableWithMethodsMixin = defineModelMixin( { quantity: tProp(types.number, 0) }, (Base) => class Countable extends Base { incrementBy(delta: number) { return this.quantity + delta } } ) // Constrained mixin – requires `quantity` to already be on the base const producerMixin = defineModelMixin( { produced: tProp(types.number, 0) }, req<{ quantity: number }>(), (Base) => class Producer extends Base { produceTotal() { return this.produced + this.quantity } } ) const ProductBase = composeMixins(Entity, countableWithMethodsMixin, producerMixin) type Product = InstanceType ``` `ModelData` and `ModelCreationData` reflect the exact prop types from all composed mixins. `composeMixins` enforces requirements at the type level: applying `producerMixin` before `countableWithMethodsMixin` is a TypeScript error. When you need to derive a constraint from an already-composed base, extract the instance type: ```ts const CountableBase = composeMixins(Entity, countableWithMethodsMixin) type Countable = InstanceType ``` You can also call `composeMixins` **without a base class** when you want to bundle mixins for reuse without any shared base. An implicit empty base is used, so you can apply the bundle to multiple concrete classes: ```ts // Bundle mixins independently of any base const ProductBundle = composeMixins(countableWithMethodsMixin, producerMixin) @model("myApp/Product") class Product extends ExtendedModel(ProductBundle, {}) {} @model("myApp/Order") class Order extends ExtendedModel(ProductBundle, {}) {} ``` You can also inline `composeMixins` directly into `ExtendedModel` when you don't need the composed base as a separate variable: ```ts @model("myApp/Product") class Product extends ExtendedModel( composeMixins(Entity, countableWithMethodsMixin, producerMixin), { /* any additional own props */ } ) {} ``` ## 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 comes with some disadvantages as well: - The model instances are created lazily and when needed rather than eagerly. - The only life-cycle event hook available, `onLazyInit`, runs lazily, meaning 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 data 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 data 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 DataModel({ 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. `withSetter` also accepts a value transform function. For example `withSetter(cloneTreeValue)` clones incoming non-primitive values before setting them, which is useful when the same object instance may be reused across different places in the tree. If you need clone options, wrap it (for example `withSetter((x) => cloneTreeValue(x, { generateNewIds: false }))`). Note that the transform function is only applied when using the generated setter method (for example, `model.setX(3)`); it is **not** applied during model construction (`new Model({ x: 3 })`) or snapshot deserialization, where the value is assigned as provided. ## 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 DataModel({ title: prop("demo"), }) { // non-observable runtime data saveCount = 0 markSaved() { this.saveCount++ } // or observable in the usual MobX way @observable isSaving = false @action setSaving(isSaving: boolean) { this.isSaving = isSaving } } ``` 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. ### `standaloneFlow` If the same idea needs asynchronous steps, use `standaloneFlow`. It follows the same rules as `standaloneAction`, but behaves like a flow and returns a promise: ```ts const renameAfterSave = standaloneFlow( "myApp/renameAfterSave", function* (todo: Todo, newText: string) { yield api.saveTodo(todo.id, { text: newText }) todo.setText(newText) } ) ``` This is useful when you want middleware support for reusable async logic without attaching that logic to a specific model class. ## 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 kind of object (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(obj, 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`). By default, arrays _cannot_ hold `undefined` values, but they _can_ hold `null` values. This rule exists for JSON compatibility. If you really need arrays with `undefined` values, you can enable them 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 rules 1 and 2 we can extract that the same non-primitive node can only be in a single tree and only once. 4. Primitive nodes are always copied by value, 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`: ```ts const rootStore = getRootStore(this) if (!rootStore) { return undefined } return rootStore.someValue ``` ::: ### `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 child objects (that 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 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 === DeepChangeType.ArraySplice) { console.log( `Array at ${change.path} had ${change.removedValues.length} items removed and ${change.addedValues.length} items added at index ${change.index}` ) } }) ``` The `DeepChange` type is a discriminated union of: - `ArraySpliceChange` - array splice with `index`, `addedValues`, `removedValues` - `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: - `path` - the path from the listener target to the changed object. - `target` - the actual object that changed. - `isInit` - whether the change happened while the tree was being initialized from defaults / snapshot data. 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. ### `onGlobalDeepChange` ```ts onGlobalDeepChange(listener: (target: object, change: DeepChange) => void): OnDeepChangeDisposer ``` `onGlobalDeepChange` listens to deep changes anywhere, rather than under one specific subtree: ```ts const disposer = onGlobalDeepChange((target, change) => { console.log("target", target) console.log("change", change) }) ``` This is mainly useful for tooling and diagnostics. In application code, prefer `onDeepChange` when you know the subtree you want to observe. ### `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: FN extends (...args: any[]) => any ? Parameters : never ): FN extends (...args: any[]) => any ? ReturnType : never ``` 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) ``` If you need to check whether a node is currently registered as a root store, use `isRootStore(node)`. ## `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.setItem("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 myPreferencesRaw = localStorage.getItem("myPreferences") // this will create a `UserPreferences` 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 = myPreferencesRaw ? fromSnapshot(UserPreferences, JSON.parse(myPreferencesRaw)) : new UserPreferences({ theme: "light" }) ``` ... 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`) becomes detached from the root store tree, so it stops saving changes when its disposer runs. At the same time, the new preferences object (`formPreferences`) becomes part of the root store tree, runs the hook, and starts saving future changes. This is why `onAttachedToRootStore` is such a good place to manage side effects. ## Sharing contextual data Although contexts are usually preferred for this case (see the [contexts](./contexts.mdx) section), root stores can also hold global runtime environment data that should be shared across the application but does not need to be serialized. For example: 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({ currentUserId: prop("user-1"), }) { env!: { environment: "development" | "staging" | "production" releaseChannel: "internal" | "public" enableDebugTools: boolean } } const rootStore = new RootStore({}) rootStore.env = { environment: "development", releaseChannel: "internal", enableDebugTools: true, } registerRootStore(rootStore) // then on another model @model("myApp/Profile") class Profile extends Model({}) { get shouldShowInternalFeatures() { const rootStore = getRootStore(this) return rootStore?.env.releaseChannel === "internal" } onAttachedToRootStore(rootStore) { if (rootStore.env.environment !== "production" && rootStore.env.enableDebugTools) { console.log("debug tools enabled?", rootStore.env.enableDebugTools) } } } ``` `getRootStore(node)` is also usually preferable to manually walking to the root and then checking it yourself, since it directly returns the registered root store or `undefined`. --- # 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. When a change is performed over a tree node, a new immutable snapshot of that node is generated. New immutable snapshots are also generated for all of its parents. Unchanged objects, however, keep their previous snapshot references. 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. ## 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 // or typed getSnapshot(type: TType, value: TypeToData): TypeToSnapshotOut ``` 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 text: string $modelType: string } ``` `getSnapshot` can be used on any tree node: any model, or any plain object / array as long as it has become attached to a model or has been manually transformed into one via `toTreeNode`. It also works on primitives, in which case the primitive is returned directly. ## Turning a snapshot back into an instance ### `fromSnapshot` ```ts fromSnapshot(sn: SnapshotInOf, options?: FromSnapshotOptions): T // or typed fromSnapshot(type: TType, sn: TypeToSnapshotIn, options?: FromSnapshotOptions): TypeToData ``` Restoring a snapshot is pretty easy as well: ```ts const todo = fromSnapshot(todoSnapshot) // or typed const todo = fromSnapshot(Todo, todoSnapshot) const bigintValue = fromSnapshot(types.bigint, "123") const createdAtSnapshot = getSnapshot(types.dateAsTimestamp, new Date(1000)) ``` If a snapshot includes `$modelType` and deserialization says the model is not in the registry, it usually means that model module was not imported at runtime (for example, import elision). In such cases either keep a runtime import (or side-effect import) or call `registerModels(...)` at startup. See [Class Models](./classModels.mdx#runtime-model-registration-registermodels). 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 text: 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 its contents and 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). When `fromSnapshot` or `applySnapshot` fails due to snapshot processing issues, it throws `SnapshotProcessingError` (extends `MobxKeystoneError`) and includes diagnostic metadata such as `path`, `actualSnapshot` (when available), and `modelTrail` (when available). Its error message is also enriched with this metadata (plus a safe value preview when available). ## 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) ``` ### `cloneTreeValue` ```ts cloneTreeValue(value: T, options?: Partial): T ``` `cloneTreeValue` is a more permissive clone helper intended for incoming values (for example from setters). - If `value` is a tree node, it behaves like `clone`. - If `value` is a plain object / array, it clones it via snapshots. - If `value` is a primitive (or not tree/snapshot-like), it returns it unchanged. Like `clone`, `generateNewIds` defaults to `true`. ```ts @model("myApp/Store") class Store extends Model({ data: prop().withSetter(cloneTreeValue), }) {} // custom clone options: // data: prop().withSetter((x) => // cloneTreeValue(x, { generateNewIds: false }) // ) ``` --- # 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". Every change generates two kinds of patches: patches from the previous value to the new value (usually just called "patches") and patches from the new value back to the previous value ("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` lets you observe the patches generated for a tree node and all its children: ```ts const disposer = onPatches(todo, (patches, inversePatches) => { console.log("patches", patches) console.log("inverse 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. ### `onGlobalPatches` ```ts onGlobalPatches(listener: OnGlobalPatchesListener): OnPatchesDisposer ``` `onGlobalPatches` listens to patch activity anywhere, rather than under one specific subtree: ```ts const disposer = onGlobalPatches((target, patches, inversePatches) => { console.log("target", target) console.log("patches", patches) console.log("inverse patches", inversePatches) }) ``` This is mostly useful for global tooling, logging, or diagnostics. In application code, prefer `onPatches` when you already know which subtree you want to observe. ### `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 which 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` keeps snapshots JSON-friendly, you can still work with `Map`, `Set`, `Date`, and `bigint` values. The recommended approaches are: 1. **Codecs** (`types.codec(...)` and built-in codecs) — reusable typed schemas that convert between a runtime value and its snapshot representation. Recommended for most use cases. 2. **Collection wrappers** (`asMap` / `asSet`) — lightweight function wrappers that present a `Map`/`Set` interface over plain arrays/objects. ## Codecs Codecs are typed schemas where the runtime value differs from the encoded snapshot value. They are part of the `types.*` system and integrate seamlessly with `tProp(...)`, `typeCheck(...)`, `fromSnapshot(...)`, and `getSnapshot(...)`. ### Built-in codecs These codecs are built in: | Codec | Runtime type | Snapshot type | |---|---|---| | `types.bigint` | `bigint` | `string` | | `types.dateAsTimestamp` | `Date` | `number` (timestamp) | | `types.dateAsIsoString` | `Date` | `string` (ISO 8601) | | `types.mapFromObject(valueType)` | `Map` | `Record` | | `types.mapFromArray(keyType, valueType)` | `Map` | `Array<[K, V]>` | | `types.setFromArray(valueType)` | `Set` | `Array` | ### Basic example ```ts @model("MyApp/M") class M extends Model({ id: tProp(types.bigint), createdAt: tProp(types.dateAsTimestamp), totals: tProp(types.mapFromObject(types.number), () => new Map()), tags: tProp(types.setFromArray(types.string), () => new Set()), }) {} const m = new M({ id: 1n, createdAt: new Date(), totals: new Map([["a", 1]]), tags: new Set(["x"]), }) m.id // bigint m.createdAt // Date m.totals // Map m.tags // Set getSnapshot(m) // { id: "1", createdAt: 1234567890, totals: { a: 1 }, tags: ["x"], ... } ``` ### Nested / composed codecs Codecs compose naturally with each other and with other `types.*` combinators: ```ts @model("MyApp/Schedule") class Schedule extends Model({ // Map with codec-converted keys AND values events: tProp( types.mapFromArray(types.dateAsIsoString, types.bigint), () => new Map() ), // Optional bigint limit: tProp(types.maybe(types.bigint)), // Array of dates holidays: tProp(types.array(types.dateAsTimestamp), () => []), }) {} const s = new Schedule({ events: new Map([[new Date("2024-01-01"), 42n]]), limit: undefined, holidays: [new Date("2024-12-25")], }) s.events // Map s.limit // bigint | undefined s.holidays // Date[] // Snapshot is fully JSON-serializable: // { events: [["2024-01-01T00:00:00.000Z", "42"]], limit: undefined, holidays: [1735084800000], ... } ``` ### Custom codecs You can create your own codecs with `types.codec(...)`: ```ts const urlType = types.codec({ typeName: "url", encodedType: types.string, is(value): value is URL { return value instanceof URL }, transform({ originalValue, cachedTransformedValue }) { return cachedTransformedValue ?? new URL(originalValue) }, untransform({ transformedValue, cacheTransformedValue }) { cacheTransformedValue() return transformedValue.toString() }, }) // Use it like any other type: @model("MyApp/Link") class Link extends Model({ href: tProp(urlType), altUrls: tProp(types.array(urlType), () => []), }) {} ``` See the [Runtime Type Checking](/runtime-type-checking) page for the full `types.codec(...)` API reference. :::note There are also `types.dateTimestamp` (equivalent to `types.integer`) and `types.dateString` (equivalent to `types.nonEmptyString`), but these are plain type checkers with no runtime `Date` conversion. Prefer the codec versions `types.dateAsTimestamp` and `types.dateAsIsoString` for new code. ::: ## Collection wrappers `asMap` and `asSet` are low-level utility functions that wrap plain observable objects/arrays into `Map`/`Set`-like interfaces. They are useful when you need direct control over the underlying data. Unlike codecs, they do **not** convert values. They only provide a different interface over the same underlying data. **Note: collection wrappers return the same wrapper instance for the same backing object.** ### `asMap` collection wrapper `asMap` wraps either an object of type `{ [k: string]: V }` or an array of type `[string, V][]` and exposes it as a `Map`-like 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 @model("MyApp/Settings") class Settings extends Model({ flagsByName: prop>(() => ({})), }) { get flagsMap() { return asMap(this.flagsByName) } @modelAction replaceFlagsMap(next: Map) { this.flagsByName = mapToObject(next) } } // `flagsMap` can now be used like a standard `Map` ``` ```ts @model("MyApp/Scoreboard") class Scoreboard extends Model({ entries: prop<[string, number][]>(() => []), }) { get entriesMap() { return asMap(this.entries) } @modelAction replaceEntriesMap(next: Map) { this.entries = mapToArray(next) } } // `entriesMap` can now be used like a standard `Map` ``` To convert it back to an object/array you can use `mapToObject(map)` or `mapToArray(map)`. When the map is already a collection wrapper, these return the backing object/array rather than doing a fresh conversion. ### `asSet` collection wrapper `asSet` wraps a property of type `V[]` into a `Set`-like interface: 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 @model("MyApp/Selection") class Selection extends Model({ selectedIds: prop(() => []), }) { get selectedIdSet() { return asSet(this.selectedIds) } @modelAction replaceSelectedIdSet(next: Set) { this.selectedIds = setToArray(next) } } // `selectedIdSet` can now be used like a standard `Set` ``` To convert it back to an array you can use `setToArray(set)`. When the set is already a collection wrapper it returns the backed array rather than doing a conversion. --- ## Deprecated approaches The following approaches are still functional but deprecated. For each one, the recommended alternative is listed. ### Property transforms (`.withTransform(...)`) **Alternative:** Use `tProp(types.codec(...))` or a built-in codec type. Wrap with `types.skipCheck(...)` if you don't need runtime validation. Property transforms are applied per-property on top of a plain `prop(...)` declaration. They convert between the stored value and the value exposed on the model instance. Compared to codecs, property transforms: - Don't integrate with the type system (no `typeCheck(...)`, no typed `fromSnapshot`/`getSnapshot`) - Don't compose — each property declares its own transform - Don't support nested conversion (e.g. a `Map`) - Require you to manually declare snapshot types via `prop()` ```ts // ❌ Deprecated @model("MyApp/M") class M extends Model({ date: prop().withTransform(timestampToDateTransform()).withSetter(), }) {} // ✅ Preferred @model("MyApp/M") class M extends Model({ date: tProp(types.dateAsTimestamp).withSetter(), // or without runtime checking: // date: tProp(types.skipCheck(types.dateAsTimestamp)).withSetter(), }) {} ``` #### Creating a custom property transform If you have a one-off conversion that doesn't warrant a full codec, you can still create a custom property transform: ```ts // ModelPropTransform const _timestampToDateTransform: ModelPropTransform = { transform({ originalValue, cachedTransformedValue, setOriginalValue }) { return cachedTransformedValue ?? new ImmutableDate(originalValue) }, untransform({ transformedValue, cacheTransformedValue }) { if (transformedValue instanceof ImmutableDate) { cacheTransformedValue() } return +transformedValue }, } export const timestampToDateTransform = () => _timestampToDateTransform ``` However, for reusable conversions, prefer `types.codec(...)` — see the [Custom codecs](#custom-codecs) section above. ### Built-in transform helpers All built-in transform helpers are deprecated. Use the corresponding codec instead: | Deprecated transform | Codec alternative | |---|---| | `timestampToDateTransform()` | `types.dateAsTimestamp` | | `isoStringToDateTransform()` | `types.dateAsIsoString` | | `stringToBigIntTransform()` | `types.bigint` | | `objectToMapTransform()` | `types.mapFromObject(valueType)` | | `arrayToMapTransform()` | `types.mapFromArray(keyType, valueType)` | | `arrayToSetTransform()` | `types.setFromArray(valueType)` | :::tip Codecs additionally support nested conversion. For example, `types.mapFromArray(types.dateAsIsoString, types.bigint)` converts both keys and values, which is not possible with `arrayToMapTransform()`. If you want the codec conversion without runtime type-checking overhead, wrap with `types.skipCheck(...)`: ```ts tProp(types.skipCheck(types.dateAsTimestamp)) // equivalent to: prop().withTransform(timestampToDateTransform()) // but with proper snapshot typing and composability ``` ::: ### Collection models (`ObjectMap` / `ArraySet`) **Alternative:** Use `tProp(types.mapFromObject(...))`, `tProp(types.mapFromArray(...))`, or `tProp(types.setFromArray(...))`. `ObjectMap` and `ArraySet` are special model wrappers that provide `Map`-like and `Set`-like interfaces. They produce snapshots that include `$modelType` and `$modelId` metadata, whereas codecs produce clean plain objects/arrays. #### `ObjectMap` collection model ```ts // ❌ Deprecated class LegacyObjectMapStore extends Model({ myNumberMap: prop(() => objectMap()) }) {} // or without a default: // class LegacyObjectMapStore extends Model({ // myNumberMap: prop>() // }) {} // ✅ Preferred class MapFromObjectStore extends Model({ myNumberMap: tProp(types.mapFromObject(types.number), () => new Map()) }) {} ``` Snapshot representation of `ObjectMap` (includes model metadata): ```ts { $modelType: "mobx-keystone/ObjectMap", $modelId: "Td244...", items: { "key1": value1, "key2": value2, } } ``` #### `ArraySet` collection model ```ts // ❌ Deprecated class LegacyArraySetStore extends Model({ myNumberSet: prop(() => arraySet()) }) {} // or without a default: // class LegacyArraySetStore extends Model({ // myNumberSet: prop>() // }) {} // ✅ Preferred class SetFromArrayStore extends Model({ myNumberSet: tProp(types.setFromArray(types.number), () => new Set()) }) {} ``` Snapshot representation of `ArraySet` (includes model metadata): ```ts { $modelType: "mobx-keystone/ArraySet", $modelId: "Td244...", items: [ value1, value2 ] } ``` --- # 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, meaning that if such an action/flow throws, any changes performed during it will be reverted before the exception is rethrown. 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, 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 piece 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: ActionTrackingResult; value: any }` - Whether 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 in the sense that it treats all synchronous steps of an asynchronous action as part of the same context, which removes most of the differences between 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 let you share contextual or environment data deeply across a tree without having to know the exact structure of that 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 would force every unit test to provide a suitable root store. In other words, it would create an unnecessary 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) console.log(`running as ${username}`) } @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 useful because it lets you pass volatile (non-property) data to your nodes at construction time, whether that happens through `new`, `fromSnapshot`, `clone`, `toTreeNode`, and so on. For example: ```ts const envCtx = createContext(0) @model("MyApp/M") class M extends Model({ title: prop("demo"), }) { 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, the same non-primitive 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, meaning 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[]>(() => []), }) { // ... // 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 matters and you have large chunks of data that do not change after insertion, you can use `frozen`. `frozen` wraps a plain chunk of data (plain objects, arrays, primitives, or a mix of them) inside a model-like container. In development mode it makes that data immutable, while still keeping it observable by reference, snapshotable, patchable, and reactive. The main benefit is that it avoids the overhead of turning every nested object into its own tree node. Additionally, in development mode only, `frozen` deeply freezes the value you pass in and verifies that the structure is JSON-compatible so it can be snapshotted safely. For example, imagine your app stores large polygon point lists, and you know that once a polygon is added to the store it will not change. `frozen` is a good fit for that kind of immutable payload: ```ts type Polygon = { x: number; y: number }[] // not frozen yet, so `getSnapshot` won't work on it directly const myPolygon = [ { x: 10, y: 10 }, { x: 20, y: 10 }, ] // now it is frozen; in dev mode it cannot be changed anymore // and utilities like `getSnapshot` will work on it const myFrozenPolygon = frozen(myPolygon) // access the wrapped value through `.data` const firstPoint = myFrozenPolygon.data[0] ``` ## Check modes `frozen(value, checkMode)` accepts an optional `FrozenCheckMode`: - `FrozenCheckMode.DevModeOnly` - the default; validate and freeze only in development. - `FrozenCheckMode.On` - always validate and freeze. - `FrozenCheckMode.Off` - skip validation and freezing. Most applications should keep the default. The other modes are mainly useful when you want stricter checks everywhere or you are deliberately trading safety for speed in a hot path. ## Snapshot helpers If you are working directly with snapshots, two helpers are also public: ```ts toFrozenSnapshot(data: T): FrozenData isFrozenSnapshot(snapshot: unknown): snapshot is FrozenData ``` `toFrozenSnapshot` creates the snapshot representation used for frozen values, and `isFrozenSnapshot` lets you detect that representation before converting or inspecting it. --- # 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 **completely optional**. If the type safety you get from TypeScript at compile time is enough for your app, you can skip runtime checking entirely. ## 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 - `typeCheckedValue?: any` - The value/object where the type-check was invoked. - `modelTrail?: readonly string[]` - Optional model/type trail (for example during snapshot processing of nested models). - `throw()` - Throws a `TypeCheckErrorFailure` as an exception. `TypeCheckErrorFailure` extends `MobxKeystoneError`, so `instanceof MobxKeystoneError` checks work on thrown failures. 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.codec({ ... })` Creates a reusable typed codec whose runtime value differs from its encoded snapshot value. ```ts const urlType = types.codec({ typeName: "url", encodedType: types.string, is(value): value is URL { return value instanceof URL }, transform({ originalValue, cachedTransformedValue }) { return cachedTransformedValue ?? new URL(originalValue) }, untransform({ transformedValue }) { return transformedValue.toString() }, }) ``` Codecs are a typed-system feature. Use them with `types.*`, `tProp(...)`, `typeCheck(...)`, typed `fromSnapshot(...)`, and typed `getSnapshot(...)`. For plain `prop(...)` or one-off property-local transforms, keep using `.withTransform(...)`. See [Maps, Sets, Dates, BigInt](/maps-sets-dates) for guidance on when to use codecs vs. property transforms. ### `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.or` also supports a dispatcher overload: ```ts const shapeType = types.or( (sn) => (sn.kind === "circle" ? Circle : Rectangle), Circle, Rectangle ) ``` The dispatcher receives the snapshot and must return one of the provided union types. When no union branch matches during snapshot conversion, `types.or` throws `SnapshotTypeMismatchError` (a `MobxKeystoneError`) with path/type/value metadata. ### `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.bigint` A built-in codec that stores snapshots as strings and exposes `bigint` values at runtime. ```ts const bigintType = types.bigint ``` ### `types.dateAsTimestamp` A built-in codec that stores snapshots as timestamps (`number`) and exposes `Date` values at runtime. ```ts const dateType = types.dateAsTimestamp ``` ### `types.dateAsIsoString` A built-in codec that stores snapshots as ISO strings and exposes `Date` values at runtime. ```ts const dateType = types.dateAsIsoString ``` ### `types.mapFromObject(valueType)` A built-in codec that stores snapshots as `Record` and exposes `Map` values at runtime. ```ts const numberMapType = types.mapFromObject(types.number) ``` ### `types.mapFromArray(keyType, valueType)` A built-in codec that stores snapshots as `Array<[K, V]>` and exposes `Map` values at runtime. ```ts const keyedDateMapType = types.mapFromArray(types.dateAsIsoString, types.bigint) ``` ### `types.setFromArray(valueType)` A built-in codec that stores snapshots as arrays and exposes `Set` / `ObservableSet` values at runtime. ```ts const numberSetType = types.setFromArray(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.skipCheck(type)` A type that wraps another type but skips runtime validation while preserving all snapshot processing, codec conversions, and transforms. Unlike `types.unchecked()`, the wrapped type's runtime and snapshot typing is fully preserved. Use this when you want codec conversions (e.g. `types.dateAsTimestamp`, `types.bigint`, `types.mapFromObject(...)`) without paying the cost of runtime type checking on every change. ```ts // Date codec with no runtime validation const skippedDate = types.skipCheck(types.dateAsTimestamp) // BigInt codec with no runtime validation const skippedBigInt = types.skipCheck(types.bigint) // In a model @model("MyApp/M") class M extends Model({ date: tProp(types.skipCheck(types.dateAsTimestamp)), value: tProp(types.skipCheck(types.bigint)), }) {} ``` **`types.unchecked()` vs `types.skipCheck(type)`:** - `types.unchecked()` is a hard escape hatch with no type information — processors and codecs are not active. - `types.skipCheck(type)` preserves the wrapped type — processors, codecs, and transforms remain active. - In unions, `types.unchecked()` collapses the whole union; `types.skipCheck(type)` is branch-only. ### `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({ path: ["result"], expectedTypeName: "a+b", actualValue: 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`. ### Syntactic sugar for union types in `tProp` You can also use array syntax in `tProp` as an alias for `types.or(...)`. ```ts tProp([String, Number]) // equivalent to tProp(types.or(String, Number)) tProp([String, undefined]) // equivalent to tProp(types.maybe(String)) tProp([String, null]) // equivalent to tProp(types.maybeNull(String)) ``` If you need a union dispatcher, use `types.or(dispatcher, ...types)` explicitly. ### `TypeToData` / `TypeToSnapshotIn` / `TypeToSnapshotOut` It is also possible to get the runtime and snapshot types represented by a type: ```ts const t = types.object(() => { x: types.number, y: types.number }) // TypeToData = // { // x: number, // y: number // } // TypeToSnapshotIn = string // TypeToSnapshotOut = string ``` ### 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`) - `CodecTypeInfo` (`types.codec`, `types.bigint`, `types.dateAsTimestamp`, `types.dateAsIsoString`, `types.mapFromObject`, `types.mapFromArray`, `types.setFromArray`) - `UncheckedTypeInfo` (`types.unchecked`) - `SkipCheckTypeInfo` (`types.skipCheck`) - `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 you want to edit a copy of part of the state while leaving the original state untouched, then either commit or discard those edits in one step. A common example is a settings form: the user can make several changes locally, then click Save or Reset. `draft` is built for exactly that workflow. To create a draft: ```ts const myDraftObject = draft(originalObject) ``` The `draft` function returns an object with the following properties and 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 Using the preferences example above, imagine a model in your app state like this: ```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) } } ``` Now imagine a form that lets the user change those preferences, but only applies the changes when they click Save. Until then, we do not want the live app state to change. First, create a draft copy of the preferences: ```ts const preferencesDraft = draft(myRootStore.preferences) ``` You can then pass the draft data and actions separately to a form component: ```tsx preferencesDraft.commit()} onReset={() => preferencesDraft.reset()} showValidationErrors={preferencesDraft.isDirty} saveDisabled={!preferencesDraft.isDirty || preferencesDraft.data.hasValidationErrors} resetDisabled={!preferencesDraft.isDirty} /> ``` Alternatively, pass the draft object itself and let the component use the draft 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(): boolean { return this.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 meant 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. For example: ```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 that can generally coexist in the same store, but where each item type implements validation rules based on the other items currently present. Let all items implement the following interface: ```ts interface Item { error: string | undefined } ``` Let the item store be a model that contains: - an array of items currently present in the store, - a computed property that 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)) // => `false` ``` --- # 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, title: prop("draft spec"), done: prop(false), }) { @computedTree get view() { return new V({ // compute a stable/deterministic ID id: `${this.id}.view`, summary: `${this.done ? "DONE" : "TODO"} ${this.title}`, }) } } @model("myApp/V") class V extends Model({ id: idProp, summary: prop(), }) { @computed get summaryLength() { return this.summary.length } } ``` 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 keeps a `Y.js` document in sync with a `mobx-keystone` store and vice versa. This is useful when multiple clients need to stay in sync through peer-to-peer networking, an intermediate server, or another transport. Since `Y.js` is a CRDT, concurrent edits remain conflict-free, which makes optimistic updates practical even when clients go offline temporarily. ## 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. If you already have an existing `Y.Map` or `Y.Array` and want to copy JSON data into it, use the helper functions: - `applyJsonObjectToYMap(dest, source, options?)` - `applyJsonArrayToYArray(dest, source, options?)` These are useful when seeding or refreshing part of a document without manually building nested `Y.js` containers yourself. The optional `options.mode` controls how data is applied: - `"add"` - Create and insert new `Y.js` containers from the JSON data. This is the default. - `"merge"` - Recursively merge into existing `Y.Map` / `Y.Array` containers when possible, preserving existing container references. For example: ```ts const ymap = ydoc.getMap("rootStore") // First seed applyJsonObjectToYMap(ymap, snapshot) // Later, merge an updated snapshot while preserving existing nested containers applyJsonObjectToYMap(ymap, nextSnapshot, { mode: "merge" }) ``` ## 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 = YjsTextModel.withText("Hello world!") // once `text` is part of a bound tree: text.yjsText.insert(0, "Say: ") text.text // "Say: Hello world!" ``` Note that `yjsText` throws if you access it while the model is not part of a bound tree. This is due to a limitation of `Y.js`, since it only 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 keeps a [`Loro`](https://loro.dev/) document in sync with a `mobx-keystone` store and vice versa. This is useful when multiple clients need to stay in sync through peer-to-peer networking, an intermediate server, or another transport. Since `Loro` is a CRDT, concurrent edits remain conflict-free, which makes optimistic updates practical even when clients go offline temporarily. ## 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. If you already have an existing `LoroMap` or `LoroMovableList` and want to copy JSON data into it, use the helper functions: - `applyJsonObjectToLoroMap(dest, source, options?)` - `applyJsonArrayToLoroMovableList(dest, source, options?)` These are useful when seeding or refreshing part of a document without rebuilding the container tree manually. The optional `options.mode` controls how data is applied: - `"add"` - Create and insert new Loro containers from the JSON data. This is the default. - `"merge"` - Recursively merge into existing `LoroMap` / `LoroMovableList` containers when possible, preserving existing container references. For example: ```ts const loroRoot = doc.getMap("rootStore") // First seed applyJsonObjectToLoroMap(loroRoot, snapshot) // Later, merge an updated snapshot while preserving existing nested containers applyJsonObjectToLoroMap(loroRoot, nextSnapshot, { mode: "merge" }) ``` ## 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 = LoroTextModel.withText("Hello world!") // once `text` is part of a bound tree: text.insertText(0, "Say: ") text.text // "Say: Hello world!" ``` Note that `loroText` returns `undefined` while the model is not part of a bound tree. If you want to use `LoroTextModel` in a runtime type-checked property, `loroTextModelType` is the exported `types.model(...)` helper for that: ```ts @model("myApp/Doc") class DocModel extends Model({ text: tProp(loroTextModelType), }) {} ``` When working with snapshots, `isLoroTextModelSnapshot(value)` can be useful to detect whether a snapshot represents a `LoroTextModel`. ## 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, ModelAutoTypeCheckingMode, model, modelAction, 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 This example synchronizes two separate root stores via action capture and replay to simulate how two instances of an app talk to a server to stay in sync. It uses pessimistic updates: the local action is cancelled first, then run for real 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 This example synchronizes two separate root stores via the `mobx-keystone-yjs` package. See the main integration guide [here](../../integrations/yjsBinding.mdx). 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 This example synchronizes two separate root stores via the `mobx-keystone-loro` package. See the main integration guide [here](../../integrations/loroBinding.mdx). 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 (
) } ``` --- # mstMigrationGuide This is a practical, code-first guide for migrating an existing `mobx-state-tree` (MST) codebase to `mobx-keystone`. It is written for humans first (from basics to advanced), with an LLM prompt/template at the end. If you want a high-level feature comparison first, see [Comparison with mobx-state-tree](./mstComparison.mdx). ## Getting started ### What changes when moving from MST - MST models are declared with `types.model(...)`; `mobx-keystone` models are TypeScript classes (`@model` + `extends Model(...)`). - MST uses `self` inside actions/views; `mobx-keystone` uses `this` everywhere. - MST references are identifier-like; `mobx-keystone` references are explicit `Ref` objects. - MST `getEnv(self)` becomes a context (`createContext`) in `mobx-keystone`. ### Decide upfront 1. **Runtime type checking or not** - If you only need TypeScript, prefer `prop()`. - If you need runtime validation, use `tProp(...)` and `types.*`. - The worked `mobx-keystone` examples below use `tProp(...)` because it maps more directly from MST's runtime schemas and helps with snapshot migration. 2. **Persistence format** - MST persisted snapshots will not have `$modelType`. Plan a snapshot migration strategy (typed `fromSnapshot`, snapshot processors, or a one-time data migration). - If you use `tProp(...)` for a property, input snapshots for values stored in that property can often omit `$modelType` because the type is known from the property (useful when migrating persisted MST snapshots). 3. **Reference strategy** - Decide which relationships should be actual tree children vs references. ## Migration strategy This order keeps the diff reviewable and avoids chasing cascading runtime issues: 1. Convert model definitions (data shape + basic actions/views). 2. Convert async flows (`flow` -> `@modelFlow`). 3. Convert environment/dependency injection (`getEnv` -> contexts). 4. Convert references (`types.reference`/`safeReference` -> `Ref` + `rootRef`/`customRef`). 5. Convert persistence and snapshots (especially if you have stored MST snapshots). 6. Convert patches/action replay/middleware integrations. 7. Run tests, then fix edge cases (lifecycle, collections, snapshot processors). ## Quick-start conversion rules When converting a file/model, apply these in order: 1. Convert each `types.model(...)` to a class model: `@model("app/Type") class X extends Model({ ... }) {}`. 2. Convert properties into `tProp(...)` if you want MST-like runtime schemas during migration, or `prop(...)` if you only want TypeScript checking. 3. Convert `views` into `@computed` getters and normal methods (use `this`, not `self`). 4. Convert synchronous `actions` into `@modelAction` methods. 5. Convert MST `flow` into `@modelFlow` and replace `yield promise` with `yield* _await(promise)`. 6. Convert `volatile` to plain class fields (add MobX `@observable` if you need reactivity). ## Common gotchas ### 1) No implicit snapshot assignment MST commonly assigns snapshots into places where the type is “instance”. In `mobx-keystone`, model properties are usually instance-typed, so convert explicitly. ```ts // MST style (common) self.selectedTodo = cast({ id: "t1", text: "hello" }) // mobx-keystone style this.selectedTodo = fromSnapshot(Todo, { id: "t1", text: "hello" }) // or reconcile onto an existing instance: applySnapshot(this.selectedTodo, { id: "t1", text: "hello", $modelType: this.selectedTodo.$modelType }) ``` ### 2) References have a different snapshot shape MST references serialize as identifier-like values. `mobx-keystone` references are model snapshots (for example `{ id: "x", $modelType: "myRefType" }`). If you have persisted snapshots, plan a reference-snapshot migration (see “Snapshot processor migration”). ### 3) `safeReference` is explicit policy `types.safeReference` behavior is not automatic by name. In `mobx-keystone`, implement cleanup explicitly via `onResolvedValueChange` (see the worked example). ### 4) Arrays reject `undefined` by default `mobx-keystone` arrays reject `undefined` elements by default (JSON compatibility). If your MST state relies on `undefined` inside arrays, remodel to `null`/union-safe values or enable: ```ts setGlobalConfig({ allowUndefinedArrayElements: true, }) ``` ### 5) `destroy`/`isAlive` semantics differ MST has dead-node semantics (`destroy`, `isAlive`). `mobx-keystone` does not: after detach/removal, instances remain usable objects. ### 6) IDs and refs are string-based Refs require string IDs. If you used `types.identifierNumber`, keep your numeric field but implement: ```ts getRefId() { return String(this.id) } ``` If your MST model used a generated default identifier, for example: ```ts id: types.optional(types.identifier, () => `todo-${nanoid()}`) ``` the equivalent in `mobx-keystone` is: ```ts id: idProp.withGenerator(() => `todo-${nanoid()}`) ``` ### 7) Identifier mutability differs MST identifiers are effectively immutable in typical usage. In `mobx-keystone`, ID fields (including `idProp`) can be changed inside actions. If you relied on immutability, treat IDs as write-once by convention or add guards/tests. ## End-to-end example ### MST version ```ts const Todo = types .model("Todo", { id: types.identifier, text: types.string, done: types.optional(types.boolean, false), createdAt: types.Date, }) .volatile(() => ({ isSaving: false, })) .views((self) => ({ get label() { return `${self.done ? "DONE" : "TODO"} ${self.text}` }, })) .actions((self) => ({ setText(text: string) { self.text = text }, toggle() { self.done = !self.done }, load: flow(function* load() { const api = getEnv(self).api self.isSaving = true try { const dto: { text: string } = yield api.fetchTodo(self.id) self.text = dto.text } finally { self.isSaving = false } }), })) const RootStore = types .model("RootStore", { todos: types.array(Todo), selectedTodo: types.safeReference(Todo), }) .actions((self) => ({ addTodo(text: string) { self.todos.push({ id: String(Date.now()), text, done: false, createdAt: new Date() }) }, removeTodo(todo: Instance) { destroy(todo) }, select(todo?: Instance) { self.selectedTodo = cast(todo) }, })) ``` ### mobx-keystone version ```ts import { _await, createContext, detach, idProp, model, Model, modelAction, modelFlow, registerRootStore, rootRef, tProp, types, } from "mobx-keystone" // Environment context replaces getEnv() type Env = { api: { fetchTodo(id: string): Promise<{ text: string }> } } const envCtx = createContext() @model("todoApp/Todo") class Todo extends Model({ id: idProp, text: tProp(types.string).withSetter(), done: tProp(types.boolean, false), createdAt: tProp(types.skipCheck(types.dateAsTimestamp)), }) { // volatile -> observable runtime field (not part of snapshots) @observable isSaving = false @action // MobX @action, not @modelAction — avoids unnecessary middleware/patch overhead for runtime-only fields setIsSaving(val: boolean) { this.isSaving = val } @computed get label() { return `${this.done ? "DONE" : "TODO"} ${this.text}` } @modelAction toggle() { this.done = !this.done } @modelFlow *load() { const api = envCtx.get(this)!.api this.setIsSaving(true) try { const dto: { text: string } = yield* _await(api.fetchTodo(this.id)) this.setText(dto.text) } finally { this.setIsSaving(false) } } } // Safe reference: auto-detach when target is removed const todoRef = rootRef("todoApp/TodoRef", { onResolvedValueChange(ref, newValue, oldValue) { if (oldValue && !newValue) { detach(ref) } }, }) @model("todoApp/RootStore") class RootStore extends Model({ todos: tProp(types.array(Todo), () => []), selectedTodoRef: tProp(types.maybe(types.ref(todoRef))), }) { @computed get selectedTodo() { return this.selectedTodoRef?.maybeCurrent } @modelAction addTodo(text: string) { this.todos.push(new Todo({ text, done: false, createdAt: new Date() })) } @modelAction removeTodo(todo: Todo) { detach(todo) // equivalent of MST's destroy() } @modelAction select(todo: Todo | undefined) { this.selectedTodoRef = todo ? todoRef(todo) : undefined } } // Bootstrap: register root store and provide environment const env: Env = { api: { fetchTodo: (id) => fetch(`/api/todos/${id}`).then((r) => r.json()) } } const rootStore = envCtx.apply(() => new RootStore({}), env) registerRootStore(rootStore) ``` ## Topic guides ### Environment migration (`getEnv`) When MST code reads dependencies via `getEnv(self)`, migrate that dependency to a context. This is the closest equivalent to dependency injection and makes models easier to unit test. ```ts type Env = { api: { fetchTodo(id: string): Promise<{ text: string }> } } export const envCtx = createContext() // bootstrap - wrap store creation in envCtx.apply(...) const rootStore = envCtx.apply(() => new RootStore({}), env) ``` In models: ```ts const env = envCtx.get(this)! ``` ### Snapshots and persistence (MST snapshots -> mobx-keystone snapshots) - `mobx-keystone` model snapshots include a `$modelType` metadata field that MST snapshots do not have. - If you are loading old MST snapshots that do not include this field, use the typed overload of `fromSnapshot`: ```ts const todo = fromSnapshot(Todo, oldSnapshotWithoutModelType) ``` - **Important:** if a property is declared with `tProp(...)`, then **input snapshots for the value stored in that property can omit** `$modelType`, because the type is known from the property. ```ts @model("myApp/Todo") class Todo extends Model({ id: idProp, text: tProp(types.string, "") }) {} @model("myApp/Store") class Store extends Model({ // child model snapshots in this property do not need `$modelType` todos: tProp(types.array(Todo), () => []), }) {} fromSnapshot(Store, { todos: [{ id: "t1", text: "hello" }], // no `$modelType` needed here }) ``` This also applies to ref snapshots when using runtime type checking, e.g. `tProp(types.ref(todoRef))` can accept `{ id: "..." }` without `$modelType` for the ref object. ### Snapshot processor migration MST offers `preProcessSnapshot`/`postProcessSnapshot` (on model types) and `types.snapshotProcessor` (as a wrapper type). In `mobx-keystone` there are two levels: #### Model-level processors Passed as the second argument to `Model()`: ```ts @model("myApp/Todo") class Todo extends Model( { text: prop(), done: prop(false), }, { fromSnapshotProcessor(sn: { text: string; completed?: boolean }) { // convert legacy "completed" field to "done" return { ...sn, done: sn.completed ?? false } }, toSnapshotProcessor(sn) { return sn }, } ) {} ``` #### Property-level processors Chained on individual props via `.withSnapshotProcessor()`: ```ts @model("myApp/Settings") class Settings extends Model({ volume: prop().withSnapshotProcessor({ fromSnapshot: (sn: string) => Number.parseFloat(sn), toSnapshot: (sn: number) => String(sn), }), }) {} ``` ### Model composition / inheritance MST's `types.compose(A, B)` merges two model types. In `mobx-keystone`, use `ExtendedModel`: ```ts // MST const Named = types.model({ name: types.string }) const Aged = types.model({ age: types.number }) const Person = types.compose("Person", Named, Aged) // mobx-keystone @model("myApp/Named") class Named extends Model({ name: prop() }) {} @model("myApp/Person") class Person extends ExtendedModel(Named, { age: prop() }) {} ``` Note: `ExtendedModel` extends a single base class. If you need to merge more than two MST models, flatten the props or chain multiple `ExtendedModel` calls. ### Action replay, serialization, patches - `onPatch`/`applyPatch` becomes `onPatches`/`applyPatches`. - `onAction` becomes `onActionMiddleware`. - If you serialize actions over the wire, use `serializeActionCall`/`deserializeActionCall` and `applySerializedActionAndTrackNewModelIds`/`applySerializedActionAndSyncNewModelIds` as documented in [onActionMiddleware](./actionMiddlewares/onActionMiddleware.mdx). ## Appendix: API mapping cheat sheet ### Models and properties | MST | mobx-keystone | Notes | | --- | --- | --- | | `types.model("Name", {...})` | `@model("app/Name") class X extends Model({...}) {}` | Model type string must be unique app-wide. | | `types.compose(A, B)` | `class B extends ExtendedModel(A, {...}) {}` | Use class inheritance via `ExtendedModel`. | | `types.string` / `types.number` / ... | `prop()` or `tProp(types.string)` | Use `tProp` only if you need runtime type checking. | | `types.optional(T, default)` | `prop(default)` or `tProp(T, default)` | Defaults belong in property declaration. | | `types.maybe(T)` | `prop()` or `tProp(types.maybe(T))` | Optional value; `T` must include `undefined` explicitly. | | `types.maybeNull(T)` | `prop()` or `tProp(types.maybeNull(T))` | Nullable value; `T` must include `null` explicitly. | | `types.identifier` | `idProp` | Preferred model ID field. | | `types.optional(types.identifier, () => id)` | `idProp.withGenerator(() => id)` | Per-model ID generator. | | `types.identifierNumber` | Prefer string IDs (`idProp`) or keep numeric field + override `getRefId()` to return `String(id)` | `mobx-keystone` refs require string IDs. | | `types.late(() => T)` | Usually not needed with class references | Circular/lazy types are class references; use `types.late(() => T)` only in runtime type-checking declarations if needed. | ### Collections, dates, and frozen data | MST | mobx-keystone | Notes | | --- | --- | --- | | `types.array(T)` | `prop(() => [])` | Must provide default factory explicitly (MST auto-wraps in `types.optional`). | | `types.map(T)` | `tProp(types.mapFromObject(T))`, `ObjectMap`, `asMap`, or `prop>(() => ({}))` | Prefer codecs when you want a typed `Map` runtime view over JSON-safe snapshots. Wrap with `types.skipCheck(...)` to disable runtime validation. | | `types.Date` | `tProp(types.dateAsTimestamp)` or `tProp(types.dateAsIsoString)` | Codec types include runtime validation. Use `types.skipCheck(...)` to opt out of validation while keeping conversions. | | `types.frozen(...)` | `frozen(data)` and/or `tProp(types.frozen(...))` | Preserve immutability + JSON-compat behavior. | | `types.custom(...)` | `types.codec(...)` | Prefer codecs for reusable typed runtime/snapshot conversions. Use `types.skipCheck(...)` to disable validation. Use `.withTransform(...)` for prop-local transforms. | ### Runtime type checking (only needed if using `tProp`) | MST | mobx-keystone | Notes | | --- | --- | --- | | `types.union(A, B)` | `types.or(A, B)` | Note the different name. | | `types.enumeration(name, [...])` | `types.enum(MyTsEnum)` | Pass a TypeScript enum object directly. | | `types.literal(value)` | `types.literal(value)` | Same name. | | `types.refinement(T, predicate)` | `types.refinement(T, predicate, name?)` | Same concept; optional name argument for error messages. | ### Views, actions, and flows | MST | mobx-keystone | Notes | | --- | --- | --- | | `.views((self) => ({ get x() {...} }))` | `@computed get x() {...}` | Use `this` everywhere instead of `self`. | | `.views((self) => ({ fn(arg) {...} }))` | Plain class method | Non-getter views become regular methods. | | `.actions((self) => ({ ... }))` | `@modelAction` methods | Remove `self`; use `this`. | | `flow(function* ...)` | `@modelFlow *methodName() { yield* _await(...) }` | Async actions. `yield expr` becomes `yield* _await(expr)`. | | `.extend((self) => ({ views: {...}, actions: {...} }))` | `@computed` getters + `@modelAction` methods + class fields | Combine all into the class body. | ### References | MST | mobx-keystone | Notes | | --- | --- | --- | | `types.reference(Model)` | `prop>()` + `rootRef` / `customRef` | Explicit reference objects. Expose resolved value via `@computed` getter: `ref?.maybeCurrent`. | | `types.safeReference(Model)` | `rootRef`/`customRef` with `onResolvedValueChange` cleanup | Implement the safe-cleanup policy explicitly. | | Custom `get`/`set` on references | `customRef` with `getId` + `resolve` | See the [references docs](./references.mdx). | ### Snapshots, patches, and actions | MST | mobx-keystone | Notes | | --- | --- | --- | | `getSnapshot` / `applySnapshot` / `onSnapshot` | `getSnapshot` / `applySnapshot` / `onSnapshot` | Same function names. | | `onPatch` / `applyPatch` | `onPatches` / `applyPatches` | Note the plural. `onPatches` provides both patches and inverse patches. | | `applyAction(...)` | `applyAction(...)` | Replay semantics are similar; serialization format differs. | | `onAction` / `addMiddleware` | `onActionMiddleware` / `addActionMiddleware` / `actionTrackingMiddleware` | Pick the middleware level you need. | | `preProcessSnapshot` / `postProcessSnapshot` / `types.snapshotProcessor` | Model-level: `Model(props, { fromSnapshotProcessor, toSnapshotProcessor })`. Property-level: `.withSnapshotProcessor({ fromSnapshot, toSnapshot })`. | Model-level processors are options on the second argument of `Model()`. | | `clone(node)` | `clone(node)` | Same function name; generates new IDs by default. | ### Environment, lifecycle, and protection | MST | mobx-keystone | Notes | | --- | --- | --- | | `getEnv(self)` | `createContext(...).get(this)` | Contexts are the preferred dependency injection pattern. | | `.volatile((self) => ({ ... }))` | Class fields; add `@observable` + MobX `@action` if reactive | Runtime data is not snapshotted. | | `afterCreate` | `onInit` | Always fires (no lazy initialization in `mobx-keystone`). | | `afterAttach` | `onAttachedToRootStore` | Fires when attached to a registered root store tree. Can return a disposer. | | `beforeDetach` / `beforeDestroy` | Return a disposer from `onAttachedToRootStore` | No exact 1:1 hook. Use disposer or parent action cleanup. | | `unprotect` / `protect` / `isProtected` | Keep writes inside `@modelAction`; use `runUnprotected` only for bounded escape hatches | Prefer explicit action boundaries. | | `destroy(node)` | `detach(node)` or remove from parent in a `@modelAction` | Detached nodes remain usable (no dead-node errors). | | `isAlive(node)` | No direct equivalent | Detached nodes are still usable objects. | ### Tree navigation | MST | mobx-keystone | Notes | | --- | --- | --- | | `getParent(node)` | `getParent(node)` | Same name. | | `getRoot(node)` | `getRoot(node)` | Same name. | | `getPath(node)` | `getRootPath(node).path` | Returns path array from root to node. | | `isRoot(node)` | `isRoot(node)` | Same name. | | `resolveIdentifier(Type, root, id)` | `rootRef` resolution or manual tree search | No direct equivalent; use references or tree traversal. | ## Appendix: LLM prompt template for project-wide conversion Use this as a starting system/task prompt when asking an LLM to perform the migration: ```md Migrate this codebase from mobx-state-tree to mobx-keystone. Requirements: 1. Convert every MST model to a class model (`@model` + `extends Model`). 2. Convert `views` to `@computed` getters and plain methods; `actions` to `@modelAction`. 3. Convert MST `flow` to `@modelFlow` and use `yield* _await(...)`. 4. Replace `types.reference` with `Ref` plus `rootRef`/`customRef`. 5. Replace `getEnv(self)` usage with `createContext` access. 6. Remove `cast(...)` usage where possible. 7. Preserve runtime behavior and public API shape. 8. Update tests affected by changed lifecycle/reference behavior. 9. Show all changed files with explanations for non-trivial choices. 10. Do not introduce new dependencies unless required. 11. Migrate `volatile` state to class fields with `@observable`/`@action` where reactive, plain fields otherwise. 12. Migrate snapshot processors to model-level `fromSnapshotProcessor`/`toSnapshotProcessor` or per-property `.withSnapshotProcessor(...)`. 13. Replace `unprotect`-style broad writes with `@modelAction` methods; use `runUnprotected` only where unavoidable. 14. Preserve ID semantics (especially if MST code relied on immutable identifiers). 15. Convert `types.Date` to `tProp(types.dateAsTimestamp)` or `tProp(types.dateAsIsoString)` when using typed runtime schemas. 16. Convert `types.compose` to `ExtendedModel`. After conversion, list: - unresolved TODOs, - places that need manual review, - potential behavior changes. ``` ## Final migration checklist - [ ] Models compile with strict TypeScript. - [ ] All `self` references replaced with `this`. - [ ] `cast(...)` removed where possible. - [ ] Actions/flows still enforce mutation boundaries correctly. - [ ] References resolve and clean up as expected (including safe-reference behavior). - [ ] Snapshot load/save round-trips are still valid (including `$modelType` metadata). - [ ] `types.Date` properties migrated to codecs or property transforms. - [ ] `types.map`/`types.array` defaults provided explicitly. - [ ] Volatile state migrated to class fields with correct observability. - [ ] Patch/action replication paths still work (note `onPatch` -> `onPatches`, `applyPatch` -> `applyPatches`). - [ ] Environment injection migrated from `getEnv` to `createContext`. - [ ] App root store registration (`registerRootStore`) is in place where lifecycle hooks require it. - [ ] Existing tests pass, plus new tests for migrated edge cases.