Docs
Thinking in Retree
The mental model — one ownership tree, three events, subscriptions that match your component tree, reproxy identity, and managed vs raw values.
The Quickstart shows Retree working. This page explains why it works: the five ideas the whole library is built on. Every other guide assumes this page, and the Terms section at the bottom is the canonical glossary the rest of the docs link to.
Everything is a tree#
Retree.root(object) makes one object the root of a managed tree. From that
point, every non-primitive value reachable from the root — objects, arrays,
Maps, Sets, class instances — is a node in that tree. Primitive fields
(string, number, boolean) are values on nodes, not nodes themselves.
import { Retree } from "@retreejs/core";
const project = Retree.root({
title: "Launch checklist", // primitive: a field on the root node
tasks: [ // node (array)
{ text: "Write docs", done: false }, // node (object)
],
});The tree is a pure ownership tree: every node has exactly one structural
parent, and Retree.parent(node) returns it (null for the root). Assigning
a node that already lives somewhere else into a second place is not a silent
aliasing footgun — it throws:
const task = project.tasks[0];
project.tasks.push({ ...moreTaskData }); // ✅ new object, new child
otherProject.tasks.push(task); // ❌ throws: task already has a
// structural parentWhen an existing node needs to appear somewhere else, you say what you mean
with one of three explicit operations: Retree.move transfers
ownership, Retree.link stores a reactive pointer without
reparenting, and Retree.clone makes an independent copy. They get
a full page in Tree operations.
One parent per node is what makes the rest of this page possible: every change has an unambiguous location in the tree, so Retree always knows which node changed, which ancestors are affected, and which are not.
Three events#
Every change in the tree produces events, and there are exactly three kinds
(Retree.on subscribes to them directly;
the React hooks subscribe for you):
nodeChanged— a node's own fields changed: a primitive was set, or a child was added, removed, or replaced. It does not fire for changes inside an existing child.treeChanged— the node or any descendant changed. This isnodeChangedplus everything below.nodeRemoved— the node was detached from its structural parent.
const board = Retree.root({
title: "Roadmap",
cards: [{ text: "Ship docs", done: false }],
});
Retree.on(board, "nodeChanged", (_board, changes) =>
console.log("board changed", changes)
);
Retree.on(board, "treeChanged", (_board, changes) =>
console.log("board subtree changed", changes)
);
Retree.on(board.cards[0], "nodeRemoved", () => console.log("card removed"));
board.title = "Q2 Roadmap"; // ✅ nodeChanged(board), ✅ treeChanged(board)
board.cards[0].done = true; // ❌ nodeChanged(board), ✅ treeChanged(board)
board.cards.splice(0, 1); // ✅ nodeRemoved(card), ✅ treeChanged(board)The second line is the one to internalize: board.cards[0].done = true is a
nodeChanged for the card, not for the board. The board's own fields
didn't change — its cards field still points at the same array. Only
treeChanged on the board sees it.
Everything in the React package maps onto these three events:
useNode is a nodeChanged subscription,
useTree is a treeChanged subscription,
and useSelect is a filtered subscription
on top of either. The core API is covered in
Events & subscriptions.
Subscriptions match the component tree#
Because nodeChanged is scoped to one node, the natural way to use Retree in
React is to give each component its own subscription to the node it renders.
Here is a three-level tree — board → columns → cards — rendered by three
levels of components:
const CardView = React.memo(function CardView({ card }: { card: Card }) {
const state = useNode(card); // level 3: this card's own fields
return <li>{state.text}</li>;
});
function ColumnView({ column }: { column: Column }) {
const state = useNode(column); // level 2: the column's own fields
const cards = useNode(state.cards); // ...and its card list
return (
<section>
<h4>{state.title}</h4>
<ul>
{cards.map((card, i) => (
<CardView key={i} card={card} />
))}
</ul>
</section>
);
}
function BoardView({ board }: { board: Board }) {
const columns = useNode(board.columns); // level 1: the column list
return (
<div>
{columns.map((column, i) => (
<ColumnView key={i} column={column} />
))}
</div>
);
}The state tree and the component tree have the same shape, so every write finds exactly the components that render it — at any depth:
board.columns[0].cards[1].text = "Ship"; // ✅ one CardView re-renders
board.columns[0].cards.push(newCard); // ✅ one ColumnView re-renders
board.columns[0].title = "Doing"; // ✅ that ColumnView re-renders
board.columns.push(newColumn); // ✅ BoardView re-renders(The React.memo on CardView is what stops a column's re-render from
cascading into unchanged rows — and it works with plain node props because
of reproxy identity, covered next.)
Notice what's absent: no selector per component, no derived projection to keep in sync, no normalization of the nesting into flat slices. The nesting is the subscription structure, and it scales with depth — a five-level tree works the same way as this three-level one.
The flip side is the number-one thing that surprises people in the first
hour: useNode(parent) deliberately does not re-render for grandchild
writes.
If a component reads a child object's fields, that child needs its own
useNode. See Common pitfalls for the full
treatment.
Reproxy identity#
Retree hands you proxies, and it uses proxy identity to answer the question React asks constantly: "is this the same value as last render?"
When a node changes, Retree issues it a fresh proxy identity — a
reproxy. The underlying object is the same; only the wrapper identity
changes. That gives you exactly the semantics React.memo, useMemo, and
useEffect dependency arrays want, without cloning any data:
- A changed node compares as different (
old !== new). - Everything that didn't change compares as the same — siblings, unrelated subtrees, and children of the changed node that weren't themselves written to.
- Ancestors of the change get fresh identities only as far up as a
treeChangedsubscription needs to see it. With onlynodeChangedlisteners above, ancestors keep their identity.
Here is the family-tree example from the README, annotated. The root
component uses useNode; each great-grandparent component uses useTree:
const root = Retree.root({
great_grandparent_1: {
name: "Bob Sr",
grandparent_1: {
name: "Bob Jr",
parent_1: {
name: "Angie",
child_1: { name: "Megan" },
},
},
grandparent_2: { /* ... */ },
},
great_grandparent_2: { /* ... */ },
});
// Root component
const family = useNode(root);
// Great Grandparent Component 1
const greatGrandparent1 = useTree(family.great_grandparent_1);
// Great Grandparent Component 2
const greatGrandparent2 = useTree(family.great_grandparent_2);
// If we set:
greatGrandparent1.grandparent_1.name = "Beth";
// ❌ What will NOT change:
// - Root component (no render)
// - Great Grandparent Component 2 (no render)
// - old `family` value stays equal in comparisons (memo, hook deps)
// - old `greatGrandparent2` + all its children stay equal in comparisons
// - old `greatGrandparent1.grandparent_1.parent_1` stays equal
// - old `greatGrandparent1.grandparent_2` stays equal
// ✅ What will change:
// - Great Grandparent Component 1 renders
// - old `greatGrandparent1` !== new `greatGrandparent1` in comparisons
// - old `greatGrandparent1.grandparent_1` !== its new value in comparisonsThe practical consequences:
React.memoworks out of the box when you pass nodes as props. Unchanged rows bail out because their node identity didn't change; the changed row's node identity did.- Node identities are safe in
useMemo/useEffectdeps. A dep like[task]recomputes exactly when that task (or, under auseTreesubscription, its subtree) changes. - Don't cache node references across changes and compare them manually — a stale reference compares as different from the current reproxy even though it reads the same data. Read through the value your hook returned this render.
Managed vs raw#
Every node has two faces:
- The managed value — the proxy Retree hands you. Reads are tracked, writes emit events, identity is the reproxy described above. This is what you render, subscribe to, and mutate.
- The raw value — the plain, proxy-free object underneath, returned by
Retree.raw(node). Reads are native speed at any depth (raw subtrees are guaranteed to contain zero proxies), which makes raw the right tool for wide scans, serialization, and hot read paths.
The two are bridged in both directions:
const rawTasks = Retree.raw(project.tasks); // managed → raw (read-only view)
const managed = Retree.managed(rawTasks[0]); // raw → managed (write surface)Rules that keep the boundary safe:
- Raw is read-only. Writes must go through the managed node; writing to raw skips event emission entirely.
- Raw identity never changes. Nodes are the identity currency — never use
raw references as
React.memoprops oruseMemodeps. Retree.rawthrows for values that aren't managed nodes. When a value may come from either side of the boundary, guard withRetree.isNode(value).- Event payloads (
changes[].previous/changes[].new) are always raw values;Retree.managed(change.previous)opts back into the managed node.
In React, useRaw packages this pattern up —
it subscribes like useNode but returns [raw, toManaged] for proxy-free
render reads. See useRaw and
Performance.
What can go in a tree#
Any non-primitive value becomes a node when it enters the tree:
- Plain objects and arrays — the common case.
- Maps and Sets —
map.set,set.add,delete, andclearemit like property writes; Map values and Set members that are objects are child nodes. - Dates — mutating methods like
setTimeemitnodeChanged; calls that don't change the time don't emit. - Class instances — including your own view-model classes and
ReactiveNodesubclasses. Methods work as-is (onChange={todo.toggle}); see View models.
Primitive values (string, number, boolean, null, undefined) are
never nodes. You don't subscribe to a primitive — you subscribe to the node
that owns it and read the field through that subscription:
const counter = Retree.root({ count: 0 });
const state = useNode(counter); // ✅ subscribe to the node that owns `count`
state.count; // ✅ reading the field through it is enoughFields on a ReactiveNode can also be opted out of the tree with
@ignore — reads and writes still work, but the field
does not emit, is not proxied, and loses Retree.parent. Use it for caches
and framework handles, never as a reactive reference (that's what
@link is for).
Terms#
The canonical glossary. Other docs pages link to these anchors on first use.
Node#
Any non-primitive value inside a managed tree: objects, arrays, Maps, Sets, Dates, class instances. Nodes can be subscribed to and have a structural parent. Primitives are field values on nodes, not nodes.
Root#
The node passed to Retree.root(...) (or created by React's
useRoot). The only node with no parent —
Retree.parent(root) returns null.
Tree#
Everything reachable from one root. Retree trees are pure ownership trees: each node appears in exactly one place.
Reproxy#
The fresh proxy identity a node receives after it changes. The underlying
object is unchanged; only the wrapper identity is new, so old !== new in
comparisons while unchanged nodes keep their identity. See
Reproxy identity.
Managed value#
A value accessed through Retree's proxy: reads are tracked, writes emit
events, identity follows reproxy rules. What Retree.root, node fields, and
the React hooks give you.
Raw value#
The plain, proxy-free object behind a node, returned by Retree.raw(node).
Native-speed and guaranteed proxy-free at any depth, but read-only and
invisible to reactivity. See Managed vs raw.
Managed node#
The managed node behind a raw value, returned by Retree.managed(rawValue) —
the inverse of Retree.raw. Returns undefined for values that were never
materialized as nodes.
Link#
A reactive pointer to a node owned elsewhere in the tree, created with
Retree.link(node) (a RetreeLink with a
.current field) or the @link decorator. Replacing the pointer emits on
its owner; the target keeps its structural parent.
Move#
Transferring a node to a new structural parent with
Retree.move(node, destination, key?). The node is removed from its old
parent and emits accordingly. See Tree operations.
Clone#
A detached copy of a node's current data, created with Retree.clone(node).
The copy has no parent until you assign it into the tree, and changes to one
never affect the other.
Observational subscription#
A subscription that can notify its listener without forcing the observed
node to receive a fresh reproxy. Dependency-list subscriptions in
Retree.select / useSelect are observational: a selected dependency change
can fire the callback or re-render the component, but the node you passed in
is not reproxied. Use @select on a ReactiveNode when the owner should
emit nodeChanged instead.