retree
DocsAPIWhy Retree

Start here

  • Quickstart
  • Thinking in Retree
  • Common pitfalls

React

  • Choosing a hook
  • useRoot
  • useNode
  • useTree
  • useSelect
  • useRaw

Core

  • Events & subscriptions
  • Tree operations
  • Transactions & silent writes

View models

  • ReactiveNode & decorators
  • Setup & decorators

Going deeper

  • Performance
  • Convex integration

Start here

  • Quickstart
  • Thinking in Retree
  • Common pitfalls

React

  • Choosing a hook
  • useRoot
  • useNode
  • useTree
  • useSelect
  • useRaw

Core

  • Events & subscriptions
  • Tree operations
  • Transactions & silent writes

View models

  • ReactiveNode & decorators
  • Setup & decorators

Going deeper

  • Performance
  • Convex integration
Loading page…

retree

Reactive object trees for React. MIT licensed.

© 2026 Ryan Bliss

Docs

  • Quickstart
  • Thinking in Retree
  • React hooks
  • Common pitfalls

Reference

  • @retreejs/core
  • @retreejs/react
  • @retreejs/convex
  • @retreejs/react-convex

Project

  • Why Retree
  • GitHub
  • npm
  • llms.txt

Docs

Edit on GitHub

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 parent

When 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 is nodeChanged plus 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 treeChanged subscription needs to see it. With only nodeChanged listeners 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 comparisons

The practical consequences:

  • React.memo works 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 / useEffect deps. A dep like [task] recomputes exactly when that task (or, under a useTree subscription, 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.memo props or useMemo deps.
  • Retree.raw throws for values that aren't managed nodes. When a value may come from either side of the boundary, guard with Retree.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, and clear emit like property writes; Map values and Set members that are objects are child nodes.
  • Dates — mutating methods like setTime emit nodeChanged; calls that don't change the time don't emit.
  • Class instances — including your own view-model classes and ReactiveNode subclasses. 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 enough

Fields 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.

← PreviousQuickstartNext →Common pitfalls

On this page

  • Everything is a tree
  • Three events
  • Subscriptions match the component tree
  • Reproxy identity
  • Managed vs raw
  • What can go in a tree
  • Terms
  • Node
  • Root
  • Tree
  • Reproxy
  • Managed value
  • Raw value
  • Managed node
  • Link
  • Move
  • Clone
  • Observational subscription