Skip to main content

Tree-Like Structure

Overview

mobx-keystone's structure is based on a tree-like structure, where each node can be one of:

  • A model instance.
  • A plain object.
  • An array.
  • A primitive value (string, boolean, number, null, undefined).

About arrays, it is interesting to note that by default they cannot hold undefined values, but they can hold null values. This rule is enforced to ensure compatibility with JSON. If you really need arrays with undefined values, it can be enabled in the global configuration:

setGlobalConfig({
allowUndefinedArrayElements: true,
})

Since the structure is a tree, this means these tree rules apply:

  1. A non-primitive (object) node can have zero or one parent.
  2. A non-primitive (object) node can have zero to infinite children.
  3. From rule 1 and 2 we can extract that a same non-primitve node can only be in a single tree and only once.
  4. Primitive nodes are always copied by value (as usual in JavaScript), so none of the rules above apply.
  5. Note that class models with the valueType: true option will get cloned automatically before getting inserted as a child of another node so, for all practical purposes, rule 3 does not apply and acts more akin to a primitive.

As an example of rule 1, this would not be allowed:

// 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:

// 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 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<T>(value: T): T. If the object is already a tree node then the same object will be returned.

Additionally, toTreeNode<TType, V>(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

getParentPath<T extends object = any>(value: object): ParentPath<T> | undefined

Returns the parent of the target plus the path from the parent to the target, or undefined if it has no parent.

getParent

getParent<T extends object = any>(value: object): T | undefined

Returns the parent object of the target object, or undefined if there's no parent.

getParentToChildPath

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

isModelDataObject(value: object): boolean

Returns true if a given object is a model interim data object ($).

getRootPath

getRootPath<T extends object = any>(value: object): RootPath<T>

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

getRoot<T extends object = any>(value: object): T

Returns the root of the target object, or itself if the target is a root.

isRoot

isRoot(value: object): boolean

Returns true if a given object is a root object.

isChildOfParent

isChildOfParent(child: object, parent: object): boolean

Returns true if the target is a "child" of the tree of the given "parent" object.

isParentOfChild

isParentOfChild(parent: object, child: object): boolean

Returns true if the target is a "parent" that has in its tree the given "child" object.

resolvePath

resolvePath<T = any>(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

findParent<T extends object = any>(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

findParentPath<T extends object = any>(child: object, predicate: (parent: object) => boolean, maxDepth = 0): FoundParentPath<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 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

findChildren<T extends object = any>(root: object, predicate: (node: object) => boolean, options?: { deep?: boolean }): ReadonlySet<T>

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

getChildrenObjects(node: object, options?: { deep?: boolean }): ReadonlySet<object>

Returns an observable set with all the children objects (this is, excluding primitives) of an object.

Pass the options object with the deep option (defaults to false) set to true to get the children deeply or false to get them shallowly.

walkTree

walkTree<T = void>(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

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

onChildAttachedTo(target: () => object, fn: (child: object) => (() => void) | void, options?: { deep?: boolean, fireForCurrentChildren?: boolean }): (runDetachDisposers: boolean) => void

Runs a callback every time a new object is attached to a given node. The callback can optionally return a disposer which will be run when the child is detached.

The optional options parameter accepts an object with the following options:

  • deep: boolean (default: false) - true if the callback should be run for all children deeply or false if it it should only run for shallow children.
  • fireForCurrentChildren: boolean (default: true) - true if the callback should be immediately called for currently attached children, false if only for future attachments.

Returns a disposer, which has a boolean parameter which should be true if pending detachment callbacks should be run, or false otherwise.

applySet

applySet<O extends object, K extends keyof O, V extends O[K]>(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.

applySet(someModel, "prop", "value")

applyDelete

applyDelete<O extends object, K extends keyof O>(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.

applyDelete(someObject, "field")

applyMethodCall

applyMethodCall<O extends object, K extends keyof O, FN extends O[K]>(node: O, methodName: K, ...args: Parameters<FN> : ReturnType<FN>

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.

const newArrayLength = applyMethodCall(someArray, "push", 1, 2, 3)

deepEquals

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.