Nodes
This guide introduces the basics of using mobx-bonsai
to create observable nodes that form a tree structure.
Creating Nodes with node
Nodes are plain observable objects/arrays that have been enhanced to support tree-traversal, snapshots and other super-powers. To create a node:
import { node } from 'mobx-bonsai';
interface Todo {
done: boolean
text: string
}
interface TodoAppState {
todoList: Todo[]
}
// Create a new node with initial data
const todoAppState = node<TodoAppState>({ todoList: [] });
Note that:
- Nodes must be kept data-only. Interact with pure functions and actions rather than embedded methods.
- A node that is already part of a tree cannot be reused in another tree or another section of the same tree. To move an object, first remove it from its current location or clone it using the
clone
function.
Nodes compose a tree. Add child nodes to parent nodes to build your state tree. Any plain structure (object/array) that gets added to an existing node is automatically transformed into a node as well, and you can use MobX actions to set them:
import { action } from 'mobx';
const addTodo = action((todoAppState: TodoAppState, todo: Todo) => {
todoAppState.push(todo)
})
Node Types
Creating Node Types
The nodeType
function creates node factories with enhanced functionality. Node types can be either untyped or typed:
Untyped Node Types
Use untyped node types when you don't need a type identifier. These are simpler but don't support some features like unique nodes:
import { nodeType } from 'mobx-bonsai';
// Create an untyped node type with defaults
const TTodo = nodeType<Todo>()
.defaults({
done: () => false
});
// Create a todo node, omitting the 'done' property since it has a default
const myTodo = TTodo({ text: 'Buy milk' });
// myTodo.done will be false
Typed Node Types
Typed node types store a type identifier in the $$type
property. This enables lifecycle hooks, unique node identification, and more robust type checking:
import { nodeType, TNode } from 'mobx-bonsai';
// Define a typed node interface
type TypedTodo = TNode<"todo", {
done: boolean
text: string
}>;
// Equivalent to { $$type: "todo", done: boolean, text: string }
// Create a typed node type
const TTypedTodo = nodeType<TypedTodo>("todo");
// Create a typed node
const todoNode = TTypedTodo({ done: true, text: 'Buy milk' });
// Equivalent to: node({ $$type: "todo", done: true, text: 'Buy milk' })
// Create a snapshot without instantiating the node
const todoSnapshot = TTypedTodo.snapshot({ done: true, text: 'Buy milk' });
Typed nodes enable these additional features:
- Lifecycle hooks like
onInit
for initialization or migrations - Ability to become unique nodes with identity
- Type discrimination in runtime code
Node Type Extensions
Both typed and untyped node types can be extended with additional functionality:
.withKey(keyProperty)
- Creating Unique Nodes
For typed nodes only, this transforms a regular node type into a unique node type. Each instance with the same type and key value will be reconciled to a single instance:
type TypedTodo = TNode<'todo', {
id: string
text: string
done: boolean
}>
// Create a unique node type with 'id' as the key property
const TTypedTodo = nodeType<TypedTodo>('todo').withKey('id');
// Create a unique node
const todo1 = TTypedTodo({
id: 't1',
text: 'Buy milk',
done: false
});
// Later, create or update a todo with the same ID
const todo1Updated = TTypedTodo({
id: 't1',
text: 'Buy milk and eggs',
done: false
});
// They are the same instance
// todo1 === todo1Updated is true
Notable features of unique nodes:
- The key property will be auto-generated if missing
- References to the same entity remain stable across your application
- Computed values and volatile state persist across reconciliations
- You can find instances by key using
TUser.findByKey("u1")
.getters(getterDefinitions)
Add getter functions that can take parameters. Use "this" to access node properties:
const TList = nodeType<List>()
.getters({
getItemAtIndex(index: number) {
return this.items[index];
},
findItemById(id: string) {
return this.items.find(item => item.id === id);
}
}));
// Use the getters
const item = TList.getItemAtIndex(list, 0);
.computeds(computedDefinitions)
Add computed properties that are cached when observed. Use "this" to access node properties:
const TTypedTodo = nodeType<TypedTodo>()
.computeds({
formattedText() {
return `${this.done ? '✓' : '○'} ${this.text}`;
},
displayText: {
get() {
return this.done ? `Completed: ${this.text}` : `Todo: ${this.text}`;
},
equals: (a, b) => a === b, // Optional equality function
}
}));
// Access computed properties
const formattedText = TTypedTodo.getFormattedText(todo);
.actions(actionDefinitions)
Add actions that modify nodes. Use "this" to access node properties:
const TTodoList = nodeType<TodoList>()
.actions({
addTodo(text: string) {
this.todos.push(TTypedTodo({ text, done: false }));
},
removeTodo(id: string) {
const index = this.todos.findIndex(todo => todo.id === id);
if (index >= 0) {
this.todos.splice(index, 1);
}
}
}));
// Use actions
TTodoList.addTodo(todoList, "Buy milk");
.settersFor(...properties)
Generate setter functions for properties:
const TTodo = nodeType<Todo>()
.settersFor("text", "done");
// Use generated setters
TTodo.setText(todo, "Buy groceries");
TTodo.setDone(todo, true);
.volatile(volatileProps)
Add non-serialized state to nodes.
const TTodo = nodeType<Todo>()
.volatile({
isSelected() {
return false;
},
editMode() {
return false;
},
});
// Access and modify volatile properties
const isSelected = TTodo.getIsSelected(todo);
TTodo.setIsSelected(todo, true);
TTodo.resetIsSelected(todo); // Reset to default
.onInit(callback)
For typed nodes, register a callback that runs when nodes are created:
const TTypedTodo = nodeType<TypedTodo>("todo");
// Run initialization when nodes are created
TTypedTodo.onInit(todo => {
console.log(`Todo created with ID: ${todo.id}`);
// You can perform initialization or migration logic here
if (!todo.createdAt) {
todo.createdAt = new Date();
}
});
.defaults(defaultGenerators)
Define default values for properties when they are undefined or omitted:
const TTodo = nodeType<Todo>()
.defaults({
done: () => false,
});
// You can omit properties with defaults when creating nodes
const todo = TTodo({ text: "Buy milk" });
console.log(todo.done); // false
// Explicit values override defaults
const importantTodo = TTodo({
text: "Call mom",
done: true,
});
console.log(importantTodo.true); // true
// Defaults are also applied when using snapshot
const todoSnapshot = TTodo.snapshot({ text: "Clean house" });
// todoSnapshot.done will be false
Default generators are only called when a property is missing. The return type of each generator must match the property type in your node's interface.
Note that defaults are applied when:
- Creating nodes with the nodeType factory (e.g.,
TTodo({ text: "example" })
) - Creating snapshots with the nodeType's snapshot function (e.g.,
TTodo.snapshot({ text: "example" })
)
But not when:
- Using the generic
node()
function - Updating properties on an existing node
.onInit(callback)
You can register initialization callbacks directly on a node type using the chainable onInit method. This callback is invoked when the node is created and returns the node type instance so that other methods can be chained:
import { nodeType } from 'mobx-bonsai';
const TTypedTodo = nodeType<TypedTodo>("todo")
.onInit(node => {
// perform initialization logic
});
Note: For nodes created with the plain node()
function, you can attach an initialization callback using the standalone onInit function. This allows you to perform setup on existing node instances:
import { node, onInit } from 'mobx-bonsai';
const todo = node({ text: 'Buy milk' });
onInit(todo, node => {
// perform initialization logic
});
Using Node Types with Arrays
Node types work with arrays too:
const TTodoList = nodeType<Todo[]>()
.computeds((list) => ({
completedCount() {
return list.filter(todo => todo.done).length;
}
}))
.actions((list) => ({
removeCompleted() {
for (let i = list.length - 1; i >= 0; i--) {
if (list[i].done) list.splice(i, 1);
}
}
}));
const todoList = TTodoList([]);
const completed = TTodoList.getCompletedCount(todoList);
TTodoList.removeCompleted(todoList);
Other Node Type Utilities
Node types in mobx-bonsai
come with several utility functions that help you work with typed nodes at runtime:
getNodeTypeAndKey(node)
This function retrieves both the node type object and key value from a node instance:
const TTypedTodo = nodeType<TypedTodo>('todo').withKey('id');
const todo = TTypedTodo({ id: 't1', text: 'Buy milk', done: false });
const { type, key } = getNodeTypeAndKey(todo);
// type: TTypedTodo node type object (contains methods like findByKey, etc.)
// key: 't1'
This function is useful when:
- You need to identify what type of node you're working with at runtime
- You need to extract the unique key from a node without knowing its structure
- You're processing nodes generically, but need type-specific behavior
The function returns an object with two properties:
type
: The node type object associated with the node, which gives you access to all the methods defined on that node typekey
: The key value for the node, orundefined
if it's not a keyed node
If the node has no type identifier (not a typed node), both properties will be undefined
.
findNodeTypeById(typeId)
This function looks up a node type object by its type identifier:
const TTypedTodo = nodeType<TypedTodo>('todo').withKey('id');
// Later in your code, retrieve the node type using its ID
const retrievedTodoType = findNodeTypeById('todo');
if (retrievedTodoType) {
// Use the retrieved node type to create or manipulate nodes
const newTodo = retrievedTodoType({ id: 'new', text: 'New task', done: false });
// Access methods on the node type
const existingTodo = retrievedTodoType.findByKey('existing-id');
}
This function is particularly useful when:
- Working with serialized data where you only have the type ID string
- Building generic components that need to work with different node types
- Implementing type-based registries or factories
- Creating plugins or extensions that need to discover registered node types
The function returns the node type object if found, or undefined
if no node type is registered with the given ID.
Both functions work together to provide a robust runtime type system that complements TypeScript's static type checking, allowing your application to make decisions based on node types and handle nodes generically while still maintaining type safety.
Customizing the Key Generator Function
The default key generator is tuned for performance and works as follows:
const baseLocalId = nanoid()
let localId = 0
function generateModelId() {
return localId.toString(36) + "-" + baseLocalId
}
Every key generated in a session gets a unique prefix (from an incrementing counter in base 36) while sharing the same suffix (the base from nanoid). This design implies that within a session, keys are unique in their first part, but the suffix remains constant.
You can override this behavior by supplying a custom key generator function via the global configuration:
setGlobalConfig({
keyGenerator: myKeyGeneratorFunction,
})
Computed Properties
Using computedProp
Use computedProp
to create computed values associated with a node.
The computedProp
function allows you to declare functional getters that receive the node as an argument, enabling a functional approach to computed properties.
Relationship with nodeType.computeds()
For most cases, the nodeType.computeds()
method is preferred over direct use of computedProp
:
// Preferred approach using nodeType.computeds()
const TTypedTodo = nodeType<TypedTodo>()
.computeds((todo) => ({
formattedText() {
return `${todo.done ? '✓' : '○'} ${todo.text}`;
},
displayText() {
return todo.done ? `Completed: ${todo.text}` : `Todo: ${todo.text}`;
}
}));
// Usage
const formattedText = TTypedTodo.getFormattedText(todo);
const displayText = TTypedTodo.getDisplayText(todo);
Why nodeType.computeds()
is preferred:
- Better organization by keeping all computed properties with their node type
- Improved autocompletion
When to use computedProp
directly:
- When you need a computed outside of a node type definition
- For ad-hoc computations on arbitrary nodes
- For simple one-off computed values
- When working with legacy code not using the node type pattern
Using computedProp
directly
Here's how to use computedProp
for computing derived values from nodes:
import { computedProp } from 'mobx-bonsai';
// Define a simple todo type
type Todo = { text: string, done: boolean }
const getFormattedText = computedProp((todo: Todo) => {
return `${todo.done ? '✓' : '○'} ${todo.text}`
})
const formattedText = getFormattedText(myTodo)
Unlike in traditional MobX where you'd use getters directly on objects:
import { observable } from 'mobx';
const myName = observable({
firstName: "John",
lastName: "Doe",
get fullName() {
return `${this.firstName} ${this.lastName}`
}
})
const fullName = myName.fullName
With mobx-bonsai
, nodes contain only data without methods or getters, so computedProp
provides a way to create computed values as standalone functions.
When a computed prop getter is used over a plain object/array (not a node), it will still work by running the computation without caching. This ensures you get a result regardless of whether you're using a node or a plain object.
Volatile State
Using volatileProp
Use volatileProp
to associate volatile state to nodes.
The volatileProp
function provides a way to associate volatile state with nodes. Volatile state is stored separately from the node's serializable data; it is only maintained while the node instance is alive and is not included in snapshots.
Relationship with nodeType.volatile()
For most cases, the nodeType.volatile()
method is preferred over direct use of volatileProp
:
// Preferred approach using nodeType.volatile()
const TTypedTodo = nodeType<TypedTodo>()
.volatile({
isSelected: () => false,
editMode: () => false,
});
// Usage
const isSelected = TTypedTodo.getIsSelected(todo);
TTypedTodo.setIsSelected(todo, true);
TTypedTodo.resetIsSelected(todo);
Why nodeType.volatile()
is preferred:
- Provides better organization by keeping all node operations together
- Automatically generates getter, setter, and reset functions
- Makes code more maintainable by grouping related functionality
When to use volatileProp
directly:
- When you need volatile state outside of a node type definition
- For ad-hoc volatile properties on arbitrary nodes
- In legacy code that doesn't use the node type pattern
Using volatileProp
directly
The signature is as follows:
function volatileProp<TTarget extends object, TValue>(
defaultValueGen: () => TValue,
): [
getter: (target: TTarget) => TValue,
setter: (target: TTarget, value: TValue) => void,
reset: (target: TTarget) => void,
]
- defaultValueGen: A function that returns the default value for the volatile property.
Below is an example that demonstrates how to define a node and use volatileProp
directly:
import { volatileProp, node } from 'mobx-bonsai';
interface ImageElement {
src: string;
width: number;
height: number;
}
const imageElement = node<ImageElement>({
src: "image.jpg",
width: 800,
height: 600
});
// Creating a volatile property directly
const [isSelected, setIsSelected, resetIsSelected] = volatileProp<ImageElement, boolean>(() => false);
// Usage
const selected = isSelected(imageElement); // false
setIsSelected(imageElement, true);
const nowSelected = isSelected(imageElement); // true
resetIsSelected(imageElement); // Reset to default value
Resetting Volatile State
When building applications using observable trees, nodes often carry transient state (volatile properties) used to manage UI interactions or temporary settings. If an item is removed from the list while selected, its transient state may persist. Later, if this item is reattached or restored (for example, through an undo operation), it might erroneously appear as selected.
A pattern to help avoid this issue is to reset volatile state when nodes are detached:
onChildAttachedTo({
target: () => rootStore,
childNodeType: undefined, // or pass a particular node type
onChildAttached: (child) => {
return () => {
// This runs when the child is detached
resetIsSelected(child);
// Or if using nodeType:
// TTypedTodo.resetIsSelected(child);
}
},
deep: true,
fireForCurrentChildren: true,
})
This pattern ensures volatile state is reset when an item is detached from the root store, and thus:
- It prevents stale UI states from affecting reattached nodes
- It eliminates errors from lingering transient settings
- It helps maintain expected behavior throughout the lifecycle of nodes in your tree
Utility Functions
clone
Use it to deeply clone a node before copying it into another part of the tree. If you are moving the node then consider removing it from the tree first before adding it back.
Note that, when cloning, all node keys are replaced with new unique keys.
asMap
The asMap
function converts a plain object, an observable object or an object node into a Map<string, value>
view that can be used to interact with the object as if it were a Map. Operations should be as fast as with a real Map:
Operation | Map | asMap (observable object) | asMap (plain object) |
---|---|---|---|
add | O(1) | O(1) | O(1) |
delete | O(1) | O(1) | O(1) |
has | O(1) | O(1) | O(1) |
clear | O(n) | O(n) | O(n) |
iteration | O(n) | O(n) | O(n) |
asSet
The asSet
function converts a plain array, an observable array or an array node into a Set-like view that can be used to interact with the array as if it were a Set. Note that since the backing store for plain arrays is an array some operations will be slower than with a real set or asSet with an observable array, namely:
Operation | Set | asSet (observable array) | asSet (plain array) |
---|---|---|---|
add | O(1) | O(1) | O(n) |
delete | O(1) | O(n) | O(n) |
has | O(1) | O(1) | O(n) |
clear | O(n) | O(n) | O(n) |
iteration | O(n) | O(n) | O(n) |
If speed is paramount and your values are strings consider using asMap
with a record that saves true when the value exists and delete when it does not.