@pyreon/state-tree — API Reference
Generated from
state-tree'ssrc/manifest.ts— the same source that powersllms.txtand MCPget_api. Do not edit this page by hand; edit the manifest. For the conceptual guide, see state-tree.
MobX-State-Tree-inspired structured state management built on Pyreon signals. Models compose state (signals), views (computeds), and actions into self-contained units that support typed snapshots, JSON-patch record/replay, and action interception middleware. Models can nest other models for tree-shaped state, and .asHook(id) provides singleton instances scoped to a store-like registry.
Features
model({ state }) or model({ schema }) — chainable builder
.views(self => ...) — chainable derived values; each layer sees prior ones
.actions(self => ...) — chainable mutators; async out of the box
.lifecycle(self => ({ afterCreate, beforeDestroy })) — instance lifecycle hooks
.volatile(self => ({...})) — signal-backed transient state; reactive but excluded from snapshots/patches
destroy(instance) / isAlive(instance) — tear down (recurses field-nested children) + liveness; actions no-op after destroy
clone(instance) / getType(instance) — independent structural copy + definition back-ref
onSnapshot(instance, cb) — microtask-coalesced snapshot subscription; onAction(instance, cb) — observe action calls
getParent / getRoot / getPath / isRoot / hasParent — tree traversal (parent tracked for field AND array/map children)
identifier() / reference(Type) / resolveIdentifier — normalized references that resolve by id to the live node
Schema mode validates + STRICTLY TYPES state from a schema passed directly — @pyreon/validate, raw zod / valibot / arktype, any Standard Schema (no adapter wrapper)
Nested model composition for tree-shaped state
getSnapshot / applySnapshot — typed recursive serialization
onPatch / applyPatch — JSON patch record and replay
addMiddleware — action interception chain (sync + async)
.create(initial) for instances, .asHook(id) for singleton hooks
Devtools subpath export with WeakRef-based registry
Complete example
A full, end-to-end usage of the package:
import { model, getSnapshot, applySnapshot, onPatch, applyPatch, addMiddleware } from '@pyreon/state-tree'
// Define a model — chainable: state, then .views(), then .actions():
const Todo = model({ state: { title: '', done: false } })
.views((self) => ({
summary: () => `${self.title()} [${self.done() ? 'x' : ' '}]`,
}))
.actions((self) => ({
toggle: () => self.done.set(!self.done()),
rename: (title: string) => self.title.set(title),
}))
const TodoList = model({ state: { todos: [] as ReturnType<typeof Todo.create>[] } })
.actions((self) => ({
add: (title: string) => {
const todo = Todo.create({ title, done: false })
self.todos.update(list => [...list, todo])
},
}))
// Create instances:
const list = TodoList.create({ todos: [] })
list.add('Write tests')
list.todos()[0].toggle()
// Snapshots — typed recursive serialization:
const snap = getSnapshot(list)
applySnapshot(list, { todos: [{ title: 'Restored', done: true }] })
// JSON patches — record/replay for undo, sync, debugging:
const patches: Patch[] = []
const dispose = onPatch(list, (patch) => patches.push(patch))
list.add('New item')
// Later: applyPatch(list, patches[0]) to replay
// Middleware — intercept any action in the tree:
addMiddleware(list, (call, next) => {
console.log(`Action: ${call.name}`, call.args)
return next(call)
})
// Singleton hook for app-wide state:
const useTodoList = TodoList.asHook('todo-list')
const shared = useTodoList() // same ModelInstance on every call
shared.add('Persisted item')Exports
| Symbol | Kind | Summary |
|---|---|---|
model | function | Define a reactive model via a chainable builder. |
SchemaModelHelpers | type | The five schema-validated mutation helpers exposed on every schema-mode model instance AND on self inside schema-mode |
DeepPartial | type | Recursive partial — every property optional at every depth. |
ModelDefinition | type | The chainable builder returned by model(). |
getSnapshot | function | Recursively serialize a model instance into a plain JSON-safe snapshot. |
applySnapshot | function | Apply a (possibly PARTIAL) snapshot to a model instance — updates only the keys PRESENT in the snapshot, leaving absent |
onPatch | function | Subscribe to JSON patches emitted by state mutations on a model instance. |
applyPatch | function | Apply one or more JSON patches to a model instance. |
addMiddleware | function | Add an action interception middleware to a model instance. |
destroy | function | Tear down a model instance: run its beforeDestroy handlers (from .lifecycle()), recursively destroy field-nested chi |
isAlive | function | Returns true while the instance is live, false after destroy(instance) (and false for a non-model-instance). |
clone | function | Structurally clone a model instance: snapshot its current state, then create a fresh, fully-independent instance from th |
getType | function | Returns the ModelDefinition that produced instance (the back-reference stored at .create() time), or undefined f |
volatile | function | Add VOLATILE state — signal-backed transient fields that are reactive (read self.x(), write self.x.set(v)) but EXCLU |
onSnapshot | function | Subscribe to snapshot changes. |
onAction | function | Observe every action call on an instance (logging, analytics, devtools). |
getParent | function | Tree-traversal helpers. |
identifier | function | Declare a state field as a model's IDENTIFIER — the field a reference() resolves against. |
reference | function | Declare a state field as a normalized REFERENCE to another model by its identifier. |
resolveIdentifier | function | Find the model instance of Type whose identifier equals id, searching root's subtree (depth-first, cycle-safe; rea |
resetHook | function | Destroy .asHook(id) singletons. |
API
model function
model({ state }) | model({ schema, initial?, onValidationError? }) → ModelDefinition; chain .views(f).actions(f) then .create(initial?) or .asHook(id)Define a reactive model via a chainable builder. Two modes (mutually exclusive): plain mode model({ state }) declares signal-backed fields with their initial values; schema mode model({ schema, initial? }) validates state via a schema and STRICTLY TYPES the instance from it. The schema can be passed DIRECTLY — @pyreon/validate's s.object(...), a raw z.object(...), valibot, arktype, or any Standard Schema-compliant validator — and the field types flow through end-to-end (self.name() is string, not unknown), no adapter wrapper required. The @pyreon/validation zodSchema / valibotSchema / arktypeSchema adapters still work (the _infer path) and are only needed for async-validator interop. Chain .views(f) for derived values and .actions(f) for mutators; both are CHAINABLE — every subsequent layer sees prior views + actions via self. Schema mode adds set / patch / deepPatch / update / reset helpers (bare names) on self and on the instance, each validated through the schema. Actions can be async; await u.fetchPosts() works end-to-end and middleware sees completion via await next(call). Returns a ModelDefinition — call .create(initial?) for an independent instance or .asHook(id) for a singleton.
Example
// Plain mode
const Counter = model({ state: { count: 0 } })
.views((self) => ({ doubled: () => self.count() * 2 }))
.actions((self) => ({ inc: () => self.count.update(n => n + 1) }))
// Schema mode — pass the schema DIRECTLY (no wrapper); types flow through.
// Works with @pyreon/validate (`s`), raw zod, valibot, arktype, any Standard Schema.
import { z } from 'zod'
const User = model({
schema: z.object({ name: z.string().min(1), age: z.number() }),
initial: { name: '', age: 0 },
})
// u.name() is string, u.age() is number — strictly typed from the schema.
.views((self) => ({ greet: () => `Hi, ${self.name()}` }))
.actions((self) => ({
rename: (next: string) => self.patch({ name: next }),
async fetchProfile() {
const res = await fetch('/api/profile')
const data = await res.json()
self.set(data)
},
}))
const u = User.create({ name: 'Alice', age: 30 })
u.greet() // "Hi, Alice"
await u.fetchProfile() // async action, awaitable
u.reset() // back to initialCommon mistakes
Mutating state outside of actions — bypasses middleware and patch recording, breaks the structured contract
Forgetting that
self.countis a signal — read withself.count(), write withself.count.set(v)or.update(fn)inside actionsNesting plain objects in state instead of child models — plain objects are not signal-backed, changes to their properties are not reactive
Confusing
self.set(validates against schema, throws on failure) withself.field.set(v)(direct signal write, bypasses validation — the documented escape hatch)Using
model({ state, views, actions })— that single-config form was REMOVED. Chain.views()/.actions()insteadDefining views/actions referencing each other across MULTIPLE
.actions()blocks but expecting tight typing —selfin each block is loosely typed at the tail (Record<string, any>) so cross-block calls work; the cost is weak inference for cross-block helpers
See also: ModelDefinition · SchemaModelHelpers · getSnapshot · applySnapshot · onPatch · addMiddleware
SchemaModelHelpers type
interface SchemaModelHelpers<TState> { set, patch, deepPatch, update<K>, reset }The five schema-validated mutation helpers exposed on every schema-mode model instance AND on self inside schema-mode action/view factories. They are BARE names (set, patch, deepPatch, update, reset) — a schema field that collides with one of them throws at .create() time (the reserved-name guard names the offending field), so pick a different field name rather than relying on a prefix. All five validate the merged result through the schema before writing to signals (or invoke onValidationError if configured). Direct signal writes (self.field.set(v)) bypass validation — the documented escape hatch. Parallel to @pyreon/store's SchemaStoreApi.
Example
// All five helpers — pick by mutation shape:
u.set({ name: 'Bob', age: 40, prefs: { theme: 'dark', density: 'cozy' } }) // full replace
u.patch({ name: 'Bob' }) // shallow merge
u.deepPatch({ prefs: { theme: 'dark' } }) // recursive merge — density survives
u.update('items', items => items.filter(x => x.id !== 1)) // transform one field
u.reset() // restore parsed initialCommon mistakes
patch({ prefs: { theme } })REPLACES the wholeprefsobject (shallow merge); usedeepPatchto keepdensityintactdeepPatchREPLACES arrays / class instances (Date, Map, Set) — only plain objects recurseupdate's transformer is(unknown) => unknown— cast at the call site for typed inference (key is constrained tokeyof TState & string)Using
updatefor multi-field changes — it transforms ONE top-level field at a time; usepatch/deepPatch/setfor multi-field
See also: model · DeepPartial
DeepPartial type
type DeepPartial<T> = T extends ReadonlyArray<unknown> ? T : T extends object ? { readonly [K in keyof T]?: DeepPartial<T[K]> } : TRecursive partial — every property optional at every depth. Used by SchemaModelHelpers.deepPatch as the partial-shape constraint. Arrays and primitives pass through unchanged (because deepPatch REPLACES them); only plain objects get the recursive optional treatment, matching the runtime merge semantics. Parallel to @pyreon/store's DeepPartial.
Example
// State { count: number; prefs: { theme: string; density: string } }
// DeepPartial admits:
deepPatch({ count: 5 }) // primitive field
deepPatch({ prefs: { theme: 'dark' } }) // partial nested object — density survives
deepPatch({ prefs: { theme: 'dark', density: 'cozy' } }) // full nested object
// Arrays REPLACE — DeepPartial<T[]> = T[], must pass full array shapeCommon mistakes
DeepPartial<T[]>isT[](no element-level optionality) — arrays REPLACE indeepPatch. To mutate array contents, useupdateClass instances (Date, Map, Set) keep their full shape under
DeepPartial— they are NOT plain objects and replace wholesale
See also: SchemaModelHelpers · model
ModelDefinition type
class ModelDefinition<TState, TViews, TActions, HasSchema, TVolatile> { views(f), actions(f), volatile(f), lifecycle(f), create(initial?), asHook(id) }The chainable builder returned by model(). Each .views(f) / .actions(f) returns a NEW ModelDefinition with the accumulated layer — immutable builder, safe to share across call sites. f receives self typed as the model AS IT IS SO FAR (state signals + prior views + prior actions + schema helpers when applicable). Type parameters: TState is the underlying value shape; TViews / TActions accumulate across chain steps; HasSchema flips to true in schema mode (adds set/patch/reset to instance type).
Example
const M = model({ schema })
.views((self) => ({ a: () => self.x() })) // self has state
.views((self) => ({ b: () => self.a() + 1 })) // self also has a
.actions((self) => ({ go: () => self.b() })) // self has a + b
.actions((self) => ({ go2: () => self.go() })) // self has a + b + goCommon mistakes
Trying to mutate
_configdirectly — it's frozen by intent. Use the chain methods.Forgetting that
.views(f).actions(g)does NOT callforgimmediately — they run inside.create(). Side effects in factories run per-instance, not per-definition.
See also: model
getSnapshot function
(instance: ModelInstance) => SnapshotRecursively serialize a model instance into a plain JSON-safe snapshot. Reads all signal values via .peek() (a one-time read — NOT a reactive computed; it does not subscribe). Nested field-models AND arrays / plain-objects that HOLD model instances (todos: Todo[], byId: { [k]: Model }) are recursively serialized; a reference field serializes as its stored ID, not the target node.
Example
const snap = getSnapshot(counter) // { count: 10 }Common mistakes
Expecting
getSnapshotto be reactive — it reads via.peek(), so it is a one-time snapshot, not acomputed. Call it again (e.g. insideonSnapshot) to get the next value.Expecting a
referencefield to serialize the target node — it serializes as the stored ID (owned instances in arrays/objects DO deep-serialize; a reference is an id by design).
See also: applySnapshot · model
applySnapshot function
(instance: ModelInstance, snapshot: Partial<Snapshot>) => voidApply a (possibly PARTIAL) snapshot to a model instance — updates only the keys PRESENT in the snapshot, leaving absent keys unchanged (a merge, not a clear). Nested field-models, arrays-of-instances (todos: Todo[]), and object-of-instances (byId: { [k]: Model }) reconcile IN PLACE: existing instances are updated from the matching elements, never replaced by plain snapshot objects, and array length changes beyond the current↔snapshot overlap are NOT reconciled (use the array's own mutation methods to add/remove). Schema mode routes through the validated patch helper, so an invalid snapshot is REJECTED. Emits replace patches.
Example
applySnapshot(counter, { count: 0 }) // reset one field
applySnapshot(app, { title: 'New' }) // merge — profile is left unchangedCommon mistakes
Expecting a partial snapshot to CLEAR unmentioned fields — it merges: keys absent from the snapshot keep their current value. Pass a full snapshot to replace everything.
Expecting a longer array snapshot to grow the list — arrays-of-instances reconcile only up to the overlap (
min(current, snapshot)); extra snapshot elements are NOT added (there is no element type to recreate them). Use the array's own add/remove action, then apply.Applying an invalid snapshot in schema mode expecting a silent write — it routes through the validated
patchand THROWS on a schema violation (the schema is the source of truth).
See also: getSnapshot · model
onPatch function
(instance: ModelInstance, listener: PatchListener) => () => voidSubscribe to JSON patches emitted by state mutations on a model instance. Each patch is a replace op carrying the JSON-pointer path (/count, /profile/name for nested) and the new value — Pyreon state is one signal per field, so a field holding an array/object emits a whole-value replace, not granular add/remove ops. Returns an unsubscribe function. Pairs with applyPatch for undo/redo and state synchronization.
Example
const dispose = onPatch(counter, (patch) => {
console.log(patch) // { op: 'replace', path: '/count', value: 11 }
})See also: applyPatch · model
applyPatch function
(instance: ModelInstance, patch: Patch | Patch[]) => voidApply one or more JSON patches to a model instance. Accepts a single patch or an array for batch replay. REPLACE-ONLY: every patch must be { op: "replace" } — any other op (add/remove/move/…) THROWS unsupported op. This mirrors what onPatch emits (Pyreon is one signal per field, so a field holding an array/object emits a whole-value replace, never granular add/remove), so an onPatch→applyPatch undo/redo round-trip is closed; a hand-authored standard JSON-Patch with add/remove is not.
Example
applyPatch(counter, { op: 'replace', path: '/count', value: 0 })Common mistakes
Passing an
add/remove/movepatch (e.g. imported from a standard JSON-Patch diff) —applyPatchthrowsunsupported op "<op>"; it accepts ONLYreplace. Feed it thereplaceopsonPatchemits, or convert your diff to whole-value replaces.Expecting a granular array patch (
/todos/0, add-at-index) — Pyreon emits a whole-valuereplaceof the field (/todos); apply the whole array value, not an element op.
See also: onPatch · model
addMiddleware function
(instance: ModelInstance, middleware: MiddlewareFn) => () => voidAdd an action interception middleware to a model instance. The middleware receives the action call context and a next function — call next(call) to proceed or return early to block the action. Returns an unsubscribe function.
Example
addMiddleware(counter, (call, next) => {
console.log(`${call.name}(${call.args.join(', ')})`)
return next(call)
})See also: model
destroy function
(instance: ModelInstance) => voidTear down a model instance: run its beforeDestroy handlers (from .lifecycle()), recursively destroy field-nested child models, drop all subscriptions (patch listeners + middleware), and mark it dead (isAlive → false). Idempotent. NOTE: this tears down SUBSCRIPTIONS + runs cleanup — it does NOT free memory. Pyreon signals have no per-signal dispose; the instance is reclaimed by GC once you drop your references. After destroy, actions + schema mutation helpers dev-warn and no-op; direct signal writes (self.field.set) stay unguarded.
Example
const clock = Clock.create() // .lifecycle(() => ({ afterCreate: start, beforeDestroy: stop }))
destroy(clock) // runs stop(), tears down subscriptions, marks dead
isAlive(clock) // falseCommon mistakes
Expecting
destroyto free memory immediately — it clears subscriptions + runsbeforeDestroy; GC reclaims the signals once you drop your referencesWriting state via
self.field.set(v)after destroy — direct signal writes are NOT guarded (only actions + schema helpers warn). Stop mutating a destroyed instanceCalling actions on a destroyed instance — they no-op + dev-warn; this usually means a stale event handler outlived the instance
See also: isAlive · model · clone
isAlive function
(instance: ModelInstance) => booleanReturns true while the instance is live, false after destroy(instance) (and false for a non-model-instance). Use to guard deferred work (a queued callback, a fetch resolution) that might land after the instance was torn down.
Example
const counter = model({ state: { count: 0 } })
.actions((self) => ({ inc: () => self.count.update((n) => n + 1) }))
.create()
// Guard deferred work (a queued callback, a fetch resolution) that
// might land after the instance was torn down:
if (isAlive(counter)) counter.inc()See also: destroy · model
clone function
<T>(instance: T) => TStructurally clone a model instance: snapshot its current state, then create a fresh, fully-independent instance from the SAME definition. The clone has its own signals, listeners, middleware, and lifecycle — mutating one never affects the other. In schema mode the snapshot is re-validated by .create(). Throws if the instance carries no definition back-reference (i.e. was not produced by ModelDefinition.create()).
Example
const draft = clone(original) // independent copy of original's current state
draft.title.set('edited') // does not touch originalCommon mistakes
Expecting
cloneto be a shallow reference copy — it is a deep structural copy viagetSnapshot+.create(); nested field-models are re-createdCloning an instance built without
ModelDefinition.create()—cloneneeds the definition back-reference and throws otherwise
See also: getType · getSnapshot · model
getType function
(instance: object) => unknownReturns the ModelDefinition that produced instance (the back-reference stored at .create() time), or undefined for an instance created without one. Pairs with clone; lets you create siblings from an instance you were handed. The static return type is unknown (the definition's generics are not recoverable at runtime) — cast it to a ModelDefinition<TState> to call .create().
Example
import type { ModelDefinition } from '@pyreon/state-tree'
// getType is typed `unknown` — cast to the definition type to instantiate siblings:
const Def = getType(instance) as ModelDefinition<{ count: number }> | undefined
const sibling = Def?.create()See also: clone · model
volatile function
.volatile(self => ({ ...initialValues })) → ModelDefinition (chainable)Add VOLATILE state — signal-backed transient fields that are reactive (read self.x(), write self.x.set(v)) but EXCLUDED from snapshots, patches, and onSnapshot. For state that should not be persisted or replayed: in-flight flags, drag/hover UI state, live object references (websockets, timers, promises). The factory returns initial VALUES; each becomes a Signal<T> on self + the instance, strictly typed. Volatile keys cannot collide with state / schema-helper / view / action / other-volatile names (throws at .create()). A volatile-only change never fires onSnapshot (it produces the same snapshot).
Example
model({ state: { items: [] as string[] } })
.volatile(() => ({ loading: false, lastError: null as Error | null }))
.actions((self) => ({
async load() {
self.loading.set(true) // reactive, not persisted
try { self.items.set(await fetchItems()) }
finally { self.loading.set(false) }
},
}))Common mistakes
Putting persistent state in
.volatile()— it is dropped from snapshots, so it will not survive serialize/restore or replay. Usestate/schemafor durable dataExpecting a volatile change to fire
onSnapshot/ emit a patch — volatile is excluded from both by design
See also: model · onSnapshot · getSnapshot
onSnapshot function
(instance: ModelInstance, listener: (snapshot) => void) => () => voidSubscribe to snapshot changes. The listener fires MICROTASK-COALESCED with the new snapshot after any STATE change — all writes in one synchronous burst (a multi-field set/patch, several signal writes in one action) collapse into a SINGLE emit on the next microtask (MST-like async semantics). Does NOT fire on subscribe. Volatile-field changes do not fire it. Returns an unsubscribe function; destroy(instance) also clears all snapshot listeners. (Implemented via the patch-write hook, NOT an effect() — so it never fires on creation and never depends on the untracked .peek() reads getSnapshot performs.)
Example
const dispose = onSnapshot(store, (snap) => {
localStorage.setItem('store', JSON.stringify(snap))
})Common mistakes
Expecting a synchronous / per-write callback —
onSnapshotis coalesced onto a microtask; read the snapshot you are handed, not a value yougetSnapshotsynchronously after a writeExpecting it to fire immediately on subscribe — it does not (unlike a reactive
effect); take an initialgetSnapshot(instance)yourself if you need the starting value
See also: getSnapshot · onPatch · model
onAction function
(instance: ModelInstance, listener: (call: ActionCall) => void) => () => voidObserve every action call on an instance (logging, analytics, devtools). The listener receives the ActionCall descriptor (name, args, path) BEFORE the action runs; it is read-only — it cannot block or alter the call (use addMiddleware for interception). Sugar over addMiddleware (a middleware that observes then unconditionally proceeds). Returns an unsubscribe function.
Example
const unsub = onAction(store, (call) => analytics.track(call.name, call.args))Common mistakes
Trying to block / mutate a call from
onAction— it is observe-only; useaddMiddlewareto intercept
See also: addMiddleware · model
getParent function
<T>(node) => T | undefined; also getRoot / getPath / isRoot / hasParentTree-traversal helpers. A model instance gets a tree PARENT when it is written into another model's state — as a field value, an ARRAY element, or a plain-object value (parent tracking runs on the initial value AND every subsequent write, so array-held children — the headline todos: Todo[] shape — are tracked, not just field-nested ones). getParent(node) → the instance node is attached under (or undefined for a root); getRoot(node) → walks to the top; getPath(node) → JSON-pointer path from the root built from each ancestor's parent-key (e.g. "/todos", "" for a root); isRoot(node) / hasParent(node) → booleans. All throw on a non-model-instance.
Example
const list = TodoList.create({ todos: [] })
list.add('write tests') // pushes a Todo into the array
const todo = list.todos()[0]
getParent(todo) // → list (array children get a parent, not just field-nested)
getRoot(todo) // → list
getPath(todo) // "/todos"
isRoot(list) // trueCommon mistakes
Expecting a parent for a child removed from an array — parent tracking sets the parent on write; a detached node keeps its last parent until GC (v1). getParent reflects the last attachment, not live membership
Expecting array INDICES in
getPath— v1 paths carry the field key (/todos), not the element index (/todos/0)Auto-attachment is one container level deep — a model nested inside an array inside an array is not auto-parented; use field or single-array nesting
See also: model · getSnapshot
identifier function
identifier<T extends string | number>(default?: T) => TDeclare a state field as a model's IDENTIFIER — the field a reference() resolves against. Plain mode: use as a field value, model({ state: { id: identifier(), name: '' } }) — it is a normal signal at runtime (initialized to the default, or ''); the marker just records WHICH field is the id on the definition. Schema mode names it via config instead: model({ schema, identifier: 'id' }). A model needs an identifier only to be the TARGET of a reference.
Example
const User = model({ state: { id: identifier(), name: '' } })
// schema mode:
const User2 = model({ schema: s.object({ id: s.string(), name: s.string() }), identifier: 'id' })See also: reference · resolveIdentifier · model
reference function
reference(TargetModel) => ReferenceField<TargetInstance>Declare a state field as a normalized REFERENCE to another model by its identifier. The field STORES the target's id (so it serializes + round-trips cleanly) but RESOLVES to the live node on read. post.author() → the target node (or undefined if unresolved); post.author.set(node | id) stores the id; post.author.id() reads the raw id; getSnapshot/applySnapshot serialize/restore the id. Resolution walks the tree from getRoot(node) for a node of the target type whose identifier equals the stored id (O(n) per read in v1 — a root id-index is a future optimization). The target type must declare an identifier().
Example
const Post = model({ state: { id: identifier(), title: '', author: reference(User) } })
// inside a store holding both users and posts:
post.author() // → the live User node (resolves via getRoot(post))
post.author.set(user) // stores user's id
post.author.id() // 'u-42'Common mistakes
Reading
referenceresolution OUTSIDE the tree — the field resolves viagetRoot(node), so the referencing node and the target must share a root; an unrooted node resolves toundefinedExpecting a
referencefield to deep-serialize its target — a reference serializes as the target's ID (so it round-trips), NOT as the node; the target serializes under its OWN owner in the tree. (Array-held OWNED instances — thetodos: Todo[]shape — DO deep-serialize ingetSnapshot; a reference is an id by design.)Referencing a model with no
identifier()—reference()/resolveIdentifierthrow without a declared identifier on the target type
See also: identifier · resolveIdentifier · getRoot · model
resolveIdentifier function
<T>(root, Type, id) => T | undefinedFind the model instance of Type whose identifier equals id, searching root's subtree (depth-first, cycle-safe; reads each node's owned state — fields, array elements, plain-object values — but does not follow references). Returns undefined if no match. Throws if Type has no identifier() declared. The resolver reference() fields use under the hood; also useful directly for ad-hoc lookups.
Example
const user = resolveIdentifier(store, User, 'u-42')See also: reference · identifier · getRoot
resetHook function
resetHook(id: string) => void; resetAllHooks() => voidDestroy .asHook(id) singletons. .asHook(id) stores ONE instance per id in a MODULE-LEVEL registry (created lazily on first call, shared for the process), so every consumer of useX = Model.asHook("x") gets the SAME instance — great for app-global state, a hazard for tests. resetHook(id) deletes that one singleton so the next asHook(id) call re-creates a fresh instance; resetAllHooks() clears the whole registry. Both are for TEST isolation (and hot-reload).
Example
const useTodos = TodoList.asHook('todos')
// tests:
afterEach(() => resetAllHooks()) // else a mutation in one test leaks to the nextCommon mistakes
Not resetting between tests — the
asHooksingleton lives in a module-level Map for the whole process, NOT per-test. State mutated in one test persists into the next; callresetAllHooks()(orresetHook(id)) inafterEach.Expecting
resetHooktodestroy()the old instance's subscriptions — it only DROPS the registry entry so the nextasHookre-creates. If code still holds the old reference, calldestroy()on it yourself.
See also: model · destroy
Package-level notes
Actions only: State mutations must go through actions — direct
.set()calls on state signals bypass middleware and patch recording. The model enforces this in dev mode.
Snapshot serialization:
getSnapshotreads via.peek()so it does not subscribe to signals. The snapshot is a one-time read, not a reactive computed.
Devtools: Import
@pyreon/state-tree/devtoolsfor a WeakRef-based registry of live model instances. Tree-shakeable — zero cost unless imported.