npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

r-state-tree

v0.11.1

Published

reactive state management library

Readme

r-state-tree

r-state-tree is a reactive state management library for building complex applications by moving state out of your render tree.

  • Stores hold application/view state as a tree and drive your UI.
  • Models hold domain state as a separate tree with snapshots, identifiers and references.
  • Views become dumb renderers that react to Stores/Models.

Installation

pnpm add r-state-tree

Agent skill

An official r-state-tree skill ships at skills/r-state-tree and node_modules/r-state-tree/skills/r-state-tree. Copy it into your agent's project skills directory for guidance on Store and Model design, lifecycle and async ownership, React integration, and architecture reviews.

Requirements

This library strongly recommends using decorators.

  • Recommended: decorators (@child, @id, @modelRef, @transient, @snapshot, @computed). Requires TypeScript 5.0+ with target: "es2022" or higher.
  • Fallback: static types configuration (no decorators) if you can't or don't want to enable decorators in your toolchain.

The library includes a decorator metadata polyfill for runtimes that don't yet natively support Symbol.metadata.

React integration

Install the optional React peers when using the r-state-tree/react entry point:

pnpm add react @preact/signals-react
import { createStore, mount, Store } from "r-state-tree";
import {
	observer,
	StoreProvider,
	useOptionalStore,
	useStore,
} from "r-state-tree/react";

class CounterStore extends Store {
	count = 0;

	increment() {
		this.count++;
	}
}

const Counter = observer(function Counter() {
	const store = useStore(CounterStore);

	return <button onClick={() => store.increment()}>{store.count}</button>;
});

const counterStore = mount(createStore(CounterStore));

function App() {
	return (
		<StoreProvider store={counterStore}>
			<Counter />
		</StoreProvider>
	);
}

observer(Component)

Wraps a function component and rerenders it when an r-state-tree/Preact Signal read during rendering changes. The result uses React's default memo behavior, so unchanged props do not cause a parent-driven rerender.

observer only manages reactive rendering. It does not inspect Store-valued props, provide Stores, mount Stores, or dispose Stores. Use StoreProvider explicitly at every React lookup boundary.

StoreProvider

Makes one realized Store available to descendants by its exact runtime class. Providers can be nested, and the nearest provider for the requested class wins. A subclass provider does not satisfy a lookup for its base Store class.

React provider ancestry is only a view lookup mechanism. It does not change the r-state-tree ownership parent, mount state, or lifetime of the Store. Unmounting a provider does not dispose its Store.

useStore(StoreType) and useOptionalStore(StoreType)

useStore(StoreType) returns the nearest Store provided for that exact class and throws a descriptive error when none exists. useOptionalStore(StoreType) uses the same lookup but returns null when no matching provider exists.

The React adapter is not loaded when importing only r-state-tree. Preact applications can use this entry point through preact/compat; native Preact integration without React compatibility aliases is not currently provided.

TypeScript config (Stage 3 decorators)

TypeScript 5+ supports TC39 Stage 3 decorators.

Only needed if you use decorators.

{
	"compilerOptions": {
		"target": "es2022",
		"module": "esnext",
		"moduleResolution": "bundler",
		"strict": true,
		"experimentalDecorators": false,
		"useDefineForClassFields": true,
		"lib": ["es2022", "dom"],
		"jsx": "react-jsx",
		"noEmit": true
	}
}
  • experimentalDecorators must be false for Stage 3 decorators.
  • useDefineForClassFields: true is recommended with modern toolchains targeting ES2022.
  • The library includes a Symbol.metadata polyfill via @tsmetadata/polyfill.

Vite / esbuild config (SSR)

When using Vite or esbuild for SSR, ensure the target is set to es2022 to support Stage 3 decorators:

// vite.config.ts
export default defineConfig({
	esbuild: {
		target: "es2022",
	},
});

Core concepts

  • Stores: application/view state containers. Create with createStore(), attach with mount(), and dispose with Symbol.dispose. Compose with @child and register owned effect/reaction behavior in constructors. Update reactive props via updateStore().
  • Models: domain state containers. Create with Model.create(). Ordinary fields participate in snapshots by default; exclude runtime-only fields with @transient. Structure with @child, identifiers via @id, and references via @modelRef.
  • Context: pass ambient behavioral dependencies through the Store tree using createContext<T>(), [Context.provide], and Context.consume(this). Context is reactive and can be overridden by descendant Stores.
  • Reactivity: powered by signals. Use observable(), computed / @computed, effect, reaction, batch, and untracked for precise updates.

Separation of concerns

  • Models: domain state + domain logic. Keep invariants, domain mutations, and computed/derived getters here. Models are serializable; use observable containers when persistent collections need in-place mutation, and mark runtime-only caches or resources with @transient. Derive values via getters.
  • Stores: application/view state + orchestration. Coordinate routing, timers, reactions, and I/O. Stores call model methods to perform domain changes. Avoid embedding domain rules in stores.

Why Stores and Models (motivation)

  • What is a Model? Persistent domain state plus domain rules. It holds identifiers, references, invariants, and exposes pure domain mutations and derived getters. It is serializable (snapshots), re-hydratable, and safe to reuse across views.
  • What is a Store? Application/view state and orchestration. It wires effects (reactions, timers, I/O), reacts to user intent, and delegates domain changes to Models. Explicit @snapshot fields can be snapshotted for session/view restoration; Models remain the durable domain source of truth.
  • Why separate?
    • Snapshots/undo/redo work cleanly when only domain lives in Models.
    • Views stay simple: UI reads derived getters, calls Store methods; Stores call Model methods.
    • Reuse: one Model can back multiple Stores/views without UI coupling.
    • Testability: Models are deterministic and easy to unit test; Stores are thin orchestrators.
    • Performance/identity: Models mutate in place; Stores manage child identity with key.
  • Quick rule of thumb:
    • If it should be in a snapshot or referenced by id, put it in a Model. Ordinary Model fields are snapshotted; use @child, @id, and @modelRef for structure and identity.
    • If it is ephemeral UI/app state or side-effect orchestration, put it in a Store.
    • Components should read from one Store; if a component needs multiple sources, compose them into a higher-level Store.

Stores

Stores describe reactive state containers composed into a tree.

import { Store, createStore, mount, child } from "r-state-tree";

class TodoStore extends Store<{ title: string }> {
	get title() {
		return this.props.title;
	}
}

class AppStore extends Store {
	@child get todo() {
		return createStore(TodoStore, { title: "Write docs" });
	}
}

const app = mount(createStore(AppStore));
app.todo.title; // "Write docs"

Fallback (no decorators) using static types:

import { Store, createStore, mount, child } from "r-state-tree";

class TodoStore extends Store<{ title: string }> {
	get title() {
		return this.props.title;
	}
}

class AppStore extends Store {
	get todo() {
		return createStore(TodoStore, { title: "Write docs" });
	}

	static types = {
		todo: child,
	};
}

const app = mount(createStore(AppStore));
app.todo.title;

Store creation, props, and typing

  • Always create with createStore() and attach with mount(). Stores cannot be constructed with new directly.
  • Prefer no custom constructor. Type stores as Store<Props> and access props via this.props.
  • Constructors may register framework-owned reactions and effects after super(props). Store registrations activate when mounting and are disposed with the store; return cleanup from effects to release resources.
  • Do not shadow or re-declare props as a class field; props is read-only. Use the generic Store<{ ... }> for typing.
class ItemStore extends Store<{ id: number; title?: string }> {
	get id() {
		return this.props.id;
	}
	get title() {
		return this.props.title ?? "";
	}
}

const root = mount(createStore(ItemStore, { id: 1, title: "Hello" }));

Updating props

Stores receive reactive props objects. Use updateStore to change them.

import { updateStore } from "r-state-tree";

updateStore(app.todo, { title: "Ship release" });

Store snapshots and mount-time hydration

Use @snapshot to opt Store-owned session/view state into snapshots. Other Store fields remain reactive but are not serialized. Props, Context values, effects, and reactions are never included.

class EditorStore extends Store {
	@snapshot draft = "";
	@snapshot scrollTop = 0;
	hoveredLine: number | null = null; // reactive, not snapshotted
}

const saved = {
	state: { draft: "Recovered", scrollTop: 120 },
	children: {},
};

const editor = mount(createStore(EditorStore), { snapshot: saved });

Hydration happens before mounted effects and reactions activate when a snapshot is passed to mount(..., { snapshot }) or applied via applySnapshot before mounting. applySnapshot(store, snapshot) also works on an already-mounted Store: state updates and child reconciliation go through the same reactive machinery as any props-driven update, so live effects and reactions simply observe the change.

toSnapshot(store) and onSnapshot(store, callback) use this shape:

type StoreSnapshot = {
	state: Record<string, unknown>;
	children: Record<string, StoreChildSnapshot | StoreChildSnapshot[] | null>;
};

Reactive child snapshots are matched within their parent child property by stable key. Pending snapshots are retained while a child property remains lazy and are applied before matching children mount. Once the property is realized, unmatched snapshots are discarded. Use stable keys for snapshotted child arrays; unkeyed children are matched positionally.

Child stores

Use @child for both single child stores and arrays.

import { child } from "r-state-tree";

class ListStore extends Store {
	items = ["Buy milk", "Walk dog"];

	@child get todos() {
		return this.items.map((title, i) =>
			createStore(TodoStore, { title, key: i })
		);
	}
}

Child store keys and identity

  • Pass a stable key (e.g., an id) when creating child stores to preserve identity across reorders.
  • @child must decorate a getter; child stores are derived from current state on access, and identity is preserved by keys.
class ItemsStore extends Store {
	items = [
		{ id: 1, title: "A" },
		{ id: 2, title: "B" },
	];

	@child get itemStores() {
		return this.items.map((it) =>
			createStore(TodoStore, { key: it.id, title: it.title })
		);
	}
}

Store lifetime and owned effects

Stores implement Disposable, and their owned effects run only during the mounted lifetime:

class TodoStore extends Store {
	constructor(props) {
		super(props);
		this.effect(() => {
			const connection = connect();
			return () => connection.close();
		});
	}
}

const store = mount(createStore(TodoStore));
store[Symbol.dispose]();

{
	using store = mount(createStore(TodoStore));
} // Symbol.dispose is called automatically

Every realized Store exposes a readonly AbortSignal for its terminal lifetime. It is active while the Store is alive and aborts when disposal begins, before child disposal and effect cleanup. This is a standard AbortSignal, not a reactive signal. Pass it to APIs that support cancellation:

class LibraryStore extends Store {
	async load() {
		const response = await fetch("/api/library", { signal: this.signal });
		const data = await response.json();
		if (this.signal.aborted) return;
		this.apply(data);
	}
}

For a promise that cannot be canceled, check the signal before applying its result:

const saved = await loadPersistedState();
if (this.signal.aborted) return;
this.applySavedState(saved);

An operation that also needs independent cancellation can combine both lifetimes where AbortSignal.any() is supported:

const operation = new AbortController();
const signal = AbortSignal.any([this.signal, operation.signal]);
const response = await fetch(url, { signal });

operation.abort(); // cancels only this operation

The Store signal only represents disposal. Per-operation cancellation, run IDs, and take-latest behavior remain application concerns; the library does not manage loading state, errors, polling, or concurrency policy.

Reactions

Create side effects that run when reactive values change:

class TodoStore extends Store {
	constructor(props) {
		super(props);
		this.reaction(
			() => this.props.title,
			(title, previousTitle) => console.log(previousTitle, "->", title)
		);
	}
}

Store.effect() and Store.reaction() register mount-scoped behavior. Mounting first links the complete store tree and commits its private mounted state bottom-up in one transaction. Registrations then activate bottom-up, so even a grandchild's initial effect sees a complete tree. A reaction establishes its initial value at activation and still skips its initial callback. Calling the disposer returned during construction cancels the pending registration; after mount, it disposes the live subscription. All registrations are disposed automatically with the store.

Registration activation is non-reentrant. If an active effect or reaction lazily materializes another child store, the new child's registrations are queued until the current callback and child getter stack have returned. This prevents initialization behavior from running inside the getter that created the store.

Context

Pass ambient behavioral dependencies through the Store tree without prop drilling:

import { createContext } from "r-state-tree";

type VoiceServices = {
	speak(text: string): void;
};

const VoiceContext = createContext<VoiceServices | null>(null);

class RootStore extends Store {
	voiceServices: VoiceServices | null = null;

	[VoiceContext.provide]() {
		return this.voiceServices;
	}

	@child get editor() {
		return createStore(EditorStore);
	}
}

class EditorStore extends Store {
	get voiceServices() {
		return VoiceContext.consume(this);
	}
}

Context is reactive and updates automatically when the provided value changes. When there is no default, consumption includes undefined because a provider may not exist:

type User = { name: string };

const UserContext = createContext<User>();
const user = UserContext.consume(store); // User | undefined

if (user) {
	console.log(user.name);
}

Use a nullable default when null better represents the missing value:

const UserContext = createContext<User | null>(null);
const user = UserContext.consume(store); // User | null

if (user !== null) {
	console.log(user.name);
}

Context is exclusively a Store-tree facility. Models express domain relationships through child Models, model references, state, ordinary fields, and explicit method parameters. Passing a Model through Store props keeps it available as an ordinary typed value; it does not attach the Model to the Store tree or expose Store Context to that Model.

Actions and batching

batch groups updates to avoid redundant reactions.

import { batch } from "r-state-tree";

batch(() => {
	app.todo.props.title = "Refactor";
});

Passing models to stores

Pass domain models to stores as ordinary typed props.

import { Model, Store, createStore, mount } from "r-state-tree";

class User extends Model {
	@id id = 0;
	name = "";
}

class ProfileStore extends Store<{ user: User }> {
	get name() {
		return this.props.user.name;
	}
}

const user = User.create({ id: 1, name: "Ada" });
const profile = mount(createStore(ProfileStore, { user }));
profile.name; // "Ada"

Fallback (no decorators) using static types:

import { Model, Store, createStore, mount, id } from "r-state-tree";

class User extends Model {
	id = 0;
	name = "";
	static types = { id };
}

class ProfileStore extends Store<{ user: User }> {}

const user = User.create({ id: 1, name: "Ada" });
const profile = mount(createStore(ProfileStore, { user }));
profile.props.user.name;

Type stores as Store<Props>; model values and model arrays use the same prop mechanism as every other dependency.

Modeling guide

Store ↔ Component mapping (quick guide)

  • Pair each container/screen component with one owning Store; components should read from a single Store.

  • The Store tree overlays the component tree: parents map to parent Stores; children map to @child Stores; lists map to arrays of child Stores with stable keys.

  • A Store can power multiple components (header/body/sidebar), but a component shouldn’t pull from multiple Stores. If it needs to, introduce a parent/adapter Store that composes and exposes exactly what the component needs.

  • Use Context for cross‑cutting concerns (theme, auth) instead of coupling components to multiple Stores.

  • Keep domain logic in Models; Stores orchestrate and delegate to Model methods, and expose derived getters for the UI.

  • Root store: mount a single root Store that composes the application via @child properties.

  • View stores: create one Store per view/route/tab. Views render from stores; stores drive view transitions.

  • Keyed children: pass { key } when creating child stores to preserve identity across reorders.

  • Models in stores: pass domain Models as typed store props and read them through this.props.

import { Store, Model, child, createStore, mount } from "r-state-tree";

class TabViewStore extends Store<{ title: string }> {}

class RootStore extends Store {
	@child get tabs() {
		return ["Home", "Profile"].map((title, i) =>
			createStore(TabViewStore, { title, key: i })
		);
	}
}

const root = mount(createStore(RootStore));

Patterns: deriving Stores from Models

Derive child stores directly from model arrays with stable keys; delegate mutations to model methods.

class ItemModel extends Model {
	@id id = 0;
	title = "";
}

class ListModel extends Model {
	@child(ItemModel) items: ItemModel[] = [];

	add(id: number, title: string) {
		this.items.push(ItemModel.create({ id, title }));
	}
	remove(id: number) {
		const i = this.items.findIndex((m) => m.id === id);
		if (i >= 0) this.items.splice(i, 1);
	}
	get titles() {
		return this.items.map((m) => m.title);
	}
}

class ItemStore extends Store<{ item: ItemModel }> {
	get title() {
		return this.props.item.title;
	}
}

class ListStore extends Store<{ list: ListModel }> {
	@child get items() {
		return this.props.list.items.map((item) =>
			createStore(ItemStore, { key: item.id, item })
		);
	}

	addItem(id: number, title: string) {
		this.props.list.add(id, title); // delegate to domain
	}
}

Mutability rules

  • Models: ordinary fields are shallow-reactive snapshot fields by default, but values are not auto-wrapped. For raw arrays/objects, prefer reassignment (immutability) so snapshots stay up to date. If you want in-place mutation (push, splice, set, etc.) to trigger updates and snapshot invalidation, store an observable() container (or signal()) in the field.
  • Stores: store fields are shallow-reactive. Use observable() containers (or signal()) when you want in-place mutations of nested values/collections to trigger updates.

Observable base class

For class instances that need to be reactive, extending the Observable base class is the supported pattern. This ensures compatibility with ES #private fields and built-in brand checks because the observable is created in the base constructor, allowing derived field initializers (including #private) to run on the observable.

import { Observable, effect } from "r-state-tree";

class Counter extends Observable {
	count = 0; // Public fields are automatically reactive
	#internal = 0; // Private fields also work perfectly

	get total() {
		return this.count + this.#internal;
	}

	increment() {
		this.count++;
		this.#internal++;
	}
}

const counter = new Counter();

// Use an effect to track and react to property changes
effect(() => {
	console.log(`Visible: ${counter.count}, Total: ${counter.total}`);
});

counter.increment();

[!IMPORTANT] > Why extends Observable? ES #private fields are brand-checked. If you wrap a class instance with observable() after it has been created (post-hoc wrapping), or if you return an observable from a standard constructor, the private state is installed on the original this, but methods run with the observable as this, causing TypeError: Cannot read private member.

extends Observable solves this by returning the observable from super(), so derived classes initialize their private fields directly on the observable receiver.

Limitations of observable(instance)

The observable() function is selective about what it makes observable. It wraps supported containers only:

  • Plain objects
  • Arrays
  • Maps and Sets
  • Dates

Functions are not observable containers. Calling observable(fn) returns the function unchanged with a dev-mode warning. However, functions stored as properties on observable objects are still automatically batched as actions when called—this existing behavior is preserved, just not via observable(fn) directly.

If you pass an arbitrary class instance, built-in (like URL, RegExp, Promise, or DOM objects), or frozen object to observable(), it will return the object unchanged and emit a warning in development mode.

This design prevents “silent failure” where an observable object appears to work but throws TypeError when accessing private members or internal slots. For your own classes, use extends Observable. For third-party or built-in objects, use composition:

// ❌ Post-hoc wrapping - returns raw URL, not an observable
const url = observable(new URL("..."));

// ✅ Composition - wrap a container instead
const state = observable({ url: new URL("...") });

Wrap values with observable() for reactivity. Collections (arrays, maps, sets) track mutations:

import { observable, effect, isObservable } from "r-state-tree";

class DataStore {
	// Wrap state with observable() for reactivity
	state = observable({ count: 0 });

	// Wrap array with observable() to track push/pop/splice etc.
	items = observable([]);
}

const store = new DataStore();

effect(() => {
	console.log("Items length:", store.items.length);
});

store.items.push({ value: 1 }); // Triggers effect
console.log(isObservable(store.items[0])); // false - shallow by default

Shallow behavior

All observables are shallow by default. Only the container's own properties are tracked—nested values are NOT wrapped:

  • Collections (Arrays, Maps, Sets) wrapped with observable() track mutations
  • Plain objects assigned to properties are NOT wrapped (helps preserve structuredClone compatibility for stored values)
  • Nested object properties do NOT trigger effects unless explicitly wrapped

Mental model: reactive property, explicit reactive value

  • Reading/writing a property on an observable container (including Stores/Models) is reactive.
  • The value you store is not auto-wrapped. If you store a plain object/array, mutating inside it won’t trigger reactions; wrap nested values with observable() (or use signal()), or use toObservableTree for a one-time deep wrap of an existing JSON-like structure.

What gets tracked in shallow mode:

| Expression | Tracked? | Why | | ----------------------- | ------------- | ------------------------------------------------ | | data.nested | ✅ Yes | Property access on the observable container | | data.nested.value | ❌ No | data.nested is a plain object, not observable | | data.nested = { ... } | ✅ Triggers | Reassigns a property on the observable container | | data.nested.value = 2 | ❌ No trigger | Mutates a plain object; container unchanged |

const data = observable({ nested: { value: 1 } });

effect(() => {
	data.nested.value; // Reads `data.nested` (tracked), then reads `.value` (not tracked)
});

data.nested.value = 2; // Does NOT trigger — mutating a plain object
data.nested = { value: 3 }; // DOES trigger — reassigning a property on the observable

To make nested reactive, wrap it explicitly:

const data = observable({ nested: observable({ value: 1 }) });
data.nested.value = 2; // Now triggers — `nested` is also observable

Plain objects (implicit reactivity)

Plain objects wrapped with observable() use implicit reactivity:

import { observable, effect } from "r-state-tree";

const state = observable({ count: 0 });

effect(() => {
	console.log(state.count); // All properties are reactive
});

state.count++; // Triggers the effect

Observables (low‑level)

Create reactive structures outside Stores/Models. Supported: Objects, Arrays, Map, Set, WeakMap, WeakSet.

  • Track reads with effect/reaction. Observables are shallow: reads are tracked on the observable container, but nested object mutations do not trigger unless you explicitly wrap nested values with observable() (or use signals).
  • Access backing values via source(value); check if something is reactive with isObservable(value).
  • Rule of thumb: source(...) returns the backing data, not a sanitizer—it's only observable-free if you didn't manually seed observables into the backing source.
  • Arrays: reading specific indices (arr[i]) or length tracks those; common mutators (push/pop/shift/unshift/splice/reverse/sort/fill) are reactive; non-index and symbol keys are not reactive.

source() and structuredClone

source(x) returns the backing value behind an observable. It is not a “observable stripper”.

One-way rule (important):

  • If you mutate through the observable wrapper (e.g. obj.prop = observable(child)), r-state-tree stores the raw backing value in source(obj).prop (unwrap-on-write).
  • If you manually mutate backing sources yourself (e.g. source(obj).prop = observable(child)), r-state-tree does not sanitize or rewrite your data. In that case, source(...) may contain observables.

If you need to pass values to APIs that require cloneable data (e.g. structuredClone, postMessage), avoid seeding observables into backing sources. Prefer reading via source(...) and keep your stored values plain/cloneable.

import { observable, effect, computed, reaction } from "r-state-tree";

// Object
const state = observable({ count: 0, nested: observable({ value: 1 }) });

effect(() => {
	// tracks reads
	state.count;
	state.nested.value;
});

state.count++;
state.nested.value++;

// Computed
const doubled = computed(() => state.count * 2);
effect(() => {
	doubled.value;
});

// Array
const arr = observable([0, 1]);
effect(() => arr[0]);
arr[0]++; // triggers; arr.push(2) does not, index 0 didn't change

// Map
const map = observable(new Map([["k", 1]]));
effect(() => map.get("k"));
map.set("k", 2); // triggers

// Set
const set = observable(new Set([1]));
effect(() => set.has(2));
set.add(2); // triggers

// Reaction (runs only on changes, skips initial)
let last: number | undefined;
reaction(
	() => state.count,
	(v) => {
		last = v;
	}
);
state.count++;

Derived collections and identity preservation

Native methods that return copies of a collection—such as Array.prototype.slice, filter, and concat, or Set.prototype.union and intersection—behave according to the Explicit Architecture:

  • Raw Return: The returned container is a plain JavaScript object (raw, non-observable).
  • Identity Preservation: Each element in the returned container maintains the same identity it had in the observable source. If an element was an observable (because it was explicitly owned), it remains an observable in the raw result.

This ensures that "derived" state is not automatically made reactive, while still allowing observers to maintain reference stability with existing objects in your state tree.

const item = observable({ id: 1 });
const arr = observable([item, { id: 2 }]);

// slice() returns a plain array
const sliced = arr.slice();
isObservable(sliced); // false

// Identity is preserved
sliced[0] === item; // true (same observable identity)

// If you want the result to be reactive, wrap it explicitly
const reactiveSlice = observable(arr.slice());

Recursively wrapping nested values (toObservableTree)

By default, observable() is shallow: only the top-level container is wrapped, and nested objects/arrays are not. This is intentional for performance and structuredClone compatibility.

toObservableTree performs a one-time initial pass that wraps all existing nested plain objects and arrays with observable(). After the initial wrap, the returned observables behave exactly like normal shallow observables — new assignments are not auto-wrapped.

import { toObservableTree, effect, source, isObservable } from "r-state-tree";

// Initial pass wraps all existing nested plain objects/arrays
const tree = toObservableTree({
	user: { name: "Alice", tags: ["admin", "active"] },
	settings: { theme: "dark" },
});

effect(() => {
	// Existing nested values are observable and tracked
	console.log(tree.user.name);
	console.log(tree.user.tags[0]);
});

// Mutations to initially-wrapped values trigger effects
tree.user.name = "Bob"; // triggers
tree.user.tags[0] = "superadmin"; // triggers

// NEW assignments are NOT auto-wrapped (normal shallow behavior)
tree.newProp = { foo: 1 };
isObservable(tree.newProp); // false — not wrapped

// Source is observable-free and clonable *as long as you don't manually seed observables*
// into backing data structures. r-state-tree unwraps observables on writes performed
// through observable containers, but it does not sanitize user-mutated backing sources.
const snapshot = structuredClone(source(tree));

Key behavior:

  • One-time pass: Only values present at call time are wrapped. New assignments afterward behave like normal observable() (shallow).
  • Not MobX-style "deep": This does NOT change the observable's behavior. It's just a convenience for wrapping an existing structure upfront.

When to use:

  • Hydrating API/JSON responses where you want all nested values observable from the start
  • Cases where manually wrapping each nested object would be tedious

Constraints:

  • Input must be JSON-like and acyclic (no circular references)
  • Only plain objects and arrays are wrapped; other types (Map, Set, Date, class instances, RegExp, Error, etc.) are left as-is and not traversed

Models and snapshots

Models capture persistent state with snapshot utilities.

Important: ordinary Model fields are snapshot fields and are shallow-reactive at the property level (assignments track), but values are not auto-wrapped. If you need nested mutations to be reactive (and to invalidate snapshot caches on in-place mutation), store observable() containers or signal() values in the field. Mark runtime-only Model fields with @transient.

import { Model, id, applySnapshot, onSnapshot, toSnapshot } from "r-state-tree";

class TodoModel extends Model {
	@id id = 0;
	title = "";
	completed = false;
}

const todo = TodoModel.create({ id: 1, title: "Learn signals" });

const stop = onSnapshot(todo, (snapshot) => {
	console.log(snapshot); // { id: 1, title: "Learn signals", completed: false }
});

todo.title = "Learn r-state-tree";
stop();

Snapshot data contract

Snapshots are plain JavaScript representations. r-state-tree creates and loads the representation; the application owns transport encoding, schema validation, normalization, and migrations. A snapshot can be passed through Zod and encoded as JSON, Protobuf, or another format.

  • Primitives: strings, numbers (including NaN and infinities), booleans, null, and undefined pass through.
  • Arrays: recursively cloned.
  • Plain objects: recursively cloned (prototype must be Object.prototype or null).
  • Dates: serialize to ISO strings (e.g., "2024-01-15T10:30:00.000Z").
  • Signals: serialize to their current .value (recursively cloned).
  • Map/Set/WeakMap/WeakSet and other non-plain objects are rejected with a descriptive error. Convert persistent values to plain structures or mark runtime-only fields with @transient.

JSON.stringify() does not preserve every supported snapshot value: it omits object properties containing undefined and converts NaN and infinities to null. Applications using JSON should validate or normalize the snapshot before stringifying it.

class Event extends Model {
	title = "Meeting";
	createdAt = new Date(); // Date → ISO string in snapshot
}

const event = Event.create();
toSnapshot(event);
// { title: "Meeting", createdAt: "2024-01-15T10:30:00.000Z" }

When the current runtime value is a Date, snapshot hydration converts its ISO string back into a Date. The default runtime shape therefore acts as the hydration shape for Dates, Signals, arrays, and plain objects.

If an implicit Model snapshot field contains a Map or class instance, Model creation fails immediately:

class M extends Model {
	cache = new Map();
}
M.create(); // ❌ Throws; use @transient for a runtime-only cache
// Error: r-state-tree: snapshots do not support Map at path "cache". ...

Convert to a plain structure instead:

class M extends Model {
	cache: Record<string, unknown> = {}; // ✅ Plain object
}

Snapshot invalidation rules

Snapshots are memoized computeds. Once a snapshot is observed (via onSnapshot, onSnapshotDiff, or toSnapshot), subsequent calls return the cached value unless a reactive dependency changes.

During normal application updates, the snapshot cache is invalidated by:

  1. Reassigning the Model snapshot field itself.
  2. Mutating observable containers (observable()) or signals (signal()) stored in the field.

Rule for application code: Treat raw Model snapshot values (plain objects/arrays) as immutable. If you mutate them in place without reassignment, the snapshot cache goes stale—onSnapshot won't fire and toSnapshot returns the old cached value.

class M extends Model {
	tags: string[] = [];

	// ❌ In-place mutation — snapshot cache goes stale
	addTagBroken(tag: string) {
		this.tags.push(tag);
	}

	// ✅ Reassign — invalidates cache, onSnapshot fires
	addTagReassign(tag: string) {
		this.tags = [...this.tags, tag];
	}
}

If you need in-place mutations and snapshot updates, wrap the value in observable() or use signal():

class M extends Model {
	// ✅ observable() container — in-place mutations invalidate cache
	items: { id: number }[] = observable([]);

	addItem(id: number) {
		this.items.push({ id }); // onSnapshot fires
	}
}

class Counter extends Model {
	// ✅ signal() — .value updates invalidate cache
	count = signal(0);

	increment() {
		this.count.value++; // onSnapshot fires
	}
}

Snapshots and persistence

  • Model snapshots capture ordinary Model fields and durable domain trees. Store snapshots capture explicit @snapshot session/view fields and keyed reactive children.
  • Hydrate/persist with applySnapshot and onSnapshot:
const STORAGE_KEY = "list";

// hydrate
const list = ListModel.create();
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) applySnapshot(list, JSON.parse(saved));

// persist
const off = onSnapshot(list, (snap) => {
	localStorage.setItem(STORAGE_KEY, JSON.stringify(snap));
});

Mutate Models through domain methods and let snapshots record changes automatically.

Model creation and named factories

Model.create() accepts a canonical snapshot. For defaults, identifier generation, normalization, migrations, or external API formats, use a named static factory that prepares the input before calling create():

class TodoModel extends Model {
	@id id = "";
	title = "";

	static new(title = "Untitled") {
		return this.create({
			id: crypto.randomUUID(),
			title: title.trim(),
		});
	}

	static fromApi(input: ApiTodo) {
		return this.create({
			id: input.todo_id,
			title: input.name.trim(),
		});
	}
}

const todo = TodoModel.new("  Write documentation  ");
todo.title; // "Write documentation"

This keeps snapshot hydration predictable: create(snapshot) and applySnapshot(model, snapshot) both consume the same canonical shape. Named factories make non-canonical inputs and generated values explicit and independently typed. Nested snapshots must already be canonical.

Model attachment and disposal

Models are inert state trees: creating or attaching one does not start reactions or effects. The public parent relationship remains reactive, so an external owner may observe it explicitly when needed:

const stop = reaction(
	() => todo.parent,
	(parent, previousParent) => {
		if (previousParent) console.log("detached", previousParent);
		if (parent) console.log("attached", parent);
	}
);

The external caller owns stop. Detachment is reversible. model[Symbol.dispose]() is terminal and recursively disposes owned child models, but never model refs.

Model configuration

Recommended: configure model properties with decorators.

Fallback: if you can't or don't want to use decorators, use static types.

import { Model, id, child, modelRef } from "r-state-tree";

class User extends Model {
	@id id = 0;
	name = "";
}

class TodoModel extends Model {
	@id id = 0;
	title = "";
	@modelRef(User) assignee?: User; // Reference to another model by ID
	@child metadata = MetadataModel.create(); // Nested child model
	@child tags: TagModel[] = []; // Array of child models
}

Fallback (no decorators) using static types:

import { Model, id, child, modelRef } from "r-state-tree";

class User extends Model {
	id = 0;
	name = "";
	static types = { id };
}

class TodoModel extends Model {
	id = 0;
	title = "";
	assignee?: User;
	metadata = MetadataModel.create();
	tags: TagModel[] = [];

	static types = {
		id,
		assignee: modelRef(User),
		metadata: child(MetadataModel),
		tags: child(TagModel),
	};
}

The child and modelRef helpers support both single values and arrays. You can also specify the child type using @child(ChildType) (decorators) or child(ChildType) (static types):

class TodoModel extends Model {
	@child(TagModel) tags: TagModel[] = []; // Type-safe array of child models
	@child(TagModel) primaryTag: TagModel | null = null; // Can switch between single and array at runtime
}

Model references

Reference models by ID using @modelRef(ModelType) (or modelRef(ModelType) in static types). The model constructor is required because identifiers are namespaced by model type:

class ProjectModel extends Model {
	@id id = 0;
	@child users: User[] = [];
	@modelRef(User) owner?: User; // Single reference
	@modelRef(User) assignees: User[] = []; // Array of references

	assignOwner(userId: number) {
		// Find user by ID and set as owner
		const user = this.users.find((u) => u.id === userId);
		this.owner = user;
	}
}

const project = ProjectModel.create({
	id: 1,
	users: [
		{ id: 1, name: "Alice" },
		{ id: 2, name: "Bob" },
	],
	owner: { id: 1 }, // Reference by ID in snapshot
});

project.owner?.name; // "Alice"

Both @child and @modelRef support runtime type switching between single values and arrays:

class ItemModel extends Model {
	@id id = 0;
	value = 0;
}

class ContainerModel extends Model {
	@child(ItemModel) items: ItemModel | ItemModel[]; // Can be single or array

	setSingle() {
		this.items = ItemModel.create({ id: 1, value: 10 });
	}

	setArray() {
		this.items = [
			ItemModel.create({ id: 2, value: 20 }),
			ItemModel.create({ id: 3, value: 30 }),
		];
	}
}

API surface

  • Stores
    • Store, createStore, mount, updateStore
  • Models
    • Model, Model.create()
    • Model configuration: ordinary fields are snapshotted; use @transient, @id, @child, and @modelRef (or their static types equivalents)
  • Store configuration
    • decorator @child or static types with child
  • Snapshots
    • onSnapshot, toSnapshot, applySnapshot, onSnapshotDiff
    • Types: Snapshot, SnapshotDiff, IdType, Configuration
  • Context
    • createContext, type Context
  • Reactivity and observables
    • observable, computed, effect, reaction, batch, untracked
    • Utilities: isObservable, source, reportObserved, reportChanged
  • Advanced
    • toObservableTree — recursively wrap nested values in a JSON-like structure
  • Signals interop
    • signal, getSignal, types Signal, ReadonlySignal

UI integration and signals interop

r-state-tree is built on @preact/signals-core. You can interoperate with signals directly:

  • Per-property signals via getSignal(obj, key).
  • Re-exported utilities: signal, computed, effect, batch, untracked, and types Signal, ReadonlySignal.
import { observable, effect, getSignal } from "r-state-tree";

const state = observable({ count: 0 });

const countSignal = getSignal(state, "count");

effect(() => {
	console.log("count:", countSignal.value);
});

// Update via signal or through the object
countSignal.value = 1;
state.count = 2;

React / Preact usage

  • Preact: use @preact/signals. Reading signal.value inside JSX is reactive; components re-render automatically.
  • React: use @preact/signals-react. Call useSignals() in a component and read signal.value in render; updates re-render the component.
// Preact
function TodoView({ store }: { store: TodoStore }) {
	const titleSignal = getSignal(store, "title");
	return <h1>{titleSignal.value}</h1>;
}

// React
import { useSignals } from "@preact/signals-react/runtime";
function TodoView({ store }: { store: TodoStore }) {
	useSignals();
	const titleSignal = getSignal(store, "title");
	return <h1>{titleSignal.value}</h1>;
}

Use the observers/renderers provided by the signals bindings for your UI library; r-state-tree will participate automatically because Stores/Models are signal-backed.

Identifier and reference rules

  • @id values are unique per exact runtime Model class within a tree. Different Model classes may use the same id; subclasses have their own identifier namespace. IDs cannot be cleared to undefined after assignment.
  • Identifiers are mutable and can be reassigned (including in snapshots) as long as the new id is unused by that exact Model class. A failed reassignment preserves the old id and its references.
  • Use @modelRef(ModelType) or modelRef(ModelType) when ids can overlap across Model classes; references resolve only within the declared Model class.
  • @modelRef(ModelType) requires an explicit model constructor. The referenced model must have an id and be attached to the tree; the ref becomes undefined when the model detaches.
  • When a model is re-attached to the same tree, compatible refs restore automatically; attaching to a different root does not restore prior refs.
  • @modelRef and @child can switch between single and array at runtime; reactions observe the property itself rather than internal array mutations.

Use findModelById for an optional, typed lookup within a model tree:

const user = findModelById(root, User, 1); // User | undefined

The lookup is reactive inside an effect or computed value. It returns undefined when the matching model changes its id, detaches, or is disposed.

Snapshot diffs

Use onSnapshotDiff to receive undo/redo payloads:

const history: SnapshotDiff[] = [];

const off = onSnapshotDiff(todo, (diff) => {
	history.push(diff);
});

todo.title = "New title";
todo.completed = true;

// Undo
applySnapshot(todo, history[history.length - 1].undo);

// Redo
applySnapshot(todo, history[history.length - 1].redo);

Do/Don’t guide

Do:

  • Keep domain operations in Models
  • Delegate from Stores to Models for domain changes
  • Use @child for child stores (getter-based)
  • Use stable key values for child stores
  • Mutate @child model collections and observable() containers in place (push/splice/set/add/etc.)
  • Treat raw Model snapshot arrays/objects as immutable: use reassignment so snapshots stay up to date (or store an observable() container / signal() in the field)

Don’t:

  • Shadow or re-declare props on stores
  • Instantiate Stores with new
  • Perform effectful work in constructors
  • Manually “sync” store state into Models (call model methods instead)

Compact code samples

Store without a constructor:

class ViewStore extends Store<{ q?: string }> {
	get q() {
		return this.props.q ?? "";
	}
}

Store with a model prop:

class ItemStore extends Store<{ item: ItemModel }> {}
const item = ItemModel.create({ id: 1, title: "X" });
const s = mount(createStore(ItemStore, { item }));

@child mapping from a model array (stable keys):

class ListStore extends Store<{ list: ListModel }> {
	@child get items() {
		return this.props.list.items.map((item) =>
			createStore(ItemStore, { key: item.id, item })
		);
	}
}

Model with in-place mutations and a derived getter:

class ListModel extends Model {
	@child(ItemModel) items: ItemModel[] = [];
	add(m: ItemModel) {
		this.items.push(m);
	}
	get count() {
		return this.items.length;
	}
}

Store lifecycle registration:

class S extends Store {
	constructor(props) {
		super(props);
		this.effect(() => {
			const resource = acquireResource();
			return () => resource.dispose();
		});
	}
}

Snapshot hydrate/persist:

const m = ListModel.create();
const saved = localStorage.getItem("m");
if (saved) applySnapshot(m, JSON.parse(saved));
const off = onSnapshot(m, (snap) =>
	localStorage.setItem("m", JSON.stringify(snap))
);

Common pitfalls

  • Forgetting stable keys for @child arrays causes identity churn.
  • Assuming deep reactivity: nested plain objects/arrays are not reactive unless you explicitly wrap them (or use toObservableTree for initial hydration).
  • Mutating raw Model snapshot arrays/objects in place (push, obj.x = 1) and expecting snapshots to update. Snapshots are memoized; use reassignment or store observable() containers / signal() values in the field.
  • Passing observables to third‑party APIs that expect cloneable/serializable values (e.g. structuredClone). Use source(value) to get the backing value. It will be observable-free for values written via r-state-tree’s observable APIs (unwrap-on-write), but source(...) is not guaranteed observable-free if you manually seed observables into backing sources.
  • Creating child stores in constructors: @child must be on getters so identity and lifecycle can be managed by the framework.
  • Passing props back into child stores during mount can create a recursive mount loop. Break the ownership cycle rather than wiring parent dependencies back through a child during mount.

Circular store creation

When child stores are created during mount with props that point back into the parent, it is easy to trigger an endless mount loop. The runtime guards this with a descriptive circular-creation error. Break the cycle so dependencies are produced independently of the child mount.

LLM implementation checklist

  • Do not thread context data via props. Provide contexts at the parent and consume them in children. Passing getX callbacks for resource/page/video/skill is a red flag—consume contexts instead.
  • Avoid aggregating contexts into a single ctx object; consume where needed or expose small, focused getters.
  • Only create stores in three places: root, @child getters, or immediately before mounting. Avoid standalone factory helpers; inline createStore in @child getters.
  • Do not wrap createStore calls with as Record<string, unknown>; fix typing instead.
  • Domain/persistent state belongs in Models; UI/app orchestration in Stores. Keep UI terms out of domain models—name domain concepts (e.g., ChatThreadsModel).
  • If state is persistent/rehydratable, model it and derive stores from the model; keep purely view/ephemeral state in stores.
  • Use r-state-tree snapshots (toSnapshot/applySnapshot) instead of hand-rolled serialize/rehydrate unless a different shape is required.
  • Keep onPersist only when syncing store state into a backing model; otherwise prefer snapshot listeners.
  • Pure, stateless helpers belong in utility modules, not as store methods. If a method does not touch this, extract it; keep coupled helpers in the store.
  • Provide stable, rarely changing resource/view/video/page data via context (resourceId, resourceType, totalPages, current page/display mode, page offsets, document title, skill/detail, video info). Children should consume context directly.
  • Use @child getters to create child stores with stable keys; avoid constructor creation. Pass only what the child needs; avoid prop drilling context.
  • Eliminate blanket casts; fix types and let inference work. Avoid any.
  • For createStore props, extend Record<string, unknown> only if needed; otherwise rely on proper prop types and context.
  • Avoid barrel files if they cause import confusion; prefer direct imports.
  • Keep file boundaries clean: one model per file; avoid piling multiple models together.
  • Do not shadow props; constructors are the registration phase for owned reactions and effects.
  • Pass models as typed props; use @child/child for child stores and stable keys for arrays.

Typing recipes

  • Type stores as Store<Props>; read this.props inside methods/getters.
  • Include model dependencies in the store's prop type and read them through this.props.
  • When a store has no props, use class X extends Store {}.

Cheat sheet

  • Configuration (no decorators): static types = { ... } with transient, snapshot, id, child, modelRef, computed
  • Decorators (Models): @transient, @id, @child, @modelRef
  • Decorators (Stores): @snapshot, @child
  • Core: createStore, mount, Symbol.dispose, updateStore
  • Snapshots: onSnapshot, toSnapshot, applySnapshot, onSnapshotDiff
  • Lifecycle: Store-owned reaction/effect, reactive Model.parent, and Symbol.dispose
  • Best practices: domain in Models; delegate from Stores; stable keys for @child; return cleanup from store effects; don’t shadow props.

Testing

The repository ships with Vitest suites covering stores, models, containers, and observable primitives.

pnpm test

Build & publishing

pnpm build

The build emits CommonJS, ESM bundles, and type declarations under dist/.

License

MIT