Your state tree, shaped like your component tree.

Mutate a plain TypeScript object; exactly the components that read it re-render.

npm i @retreejs/core @retreejs/react
const demo = Retree.root({
    tasks: [
        { id: 1, title: "Ship the quickstart", done: false, subtasks: [] },
        { id: 2, title: "Write tests", done: true, subtasks: [/* 3 deep */] },
    ],
    stats: { count: 0 },
});

function TaskRow({ task }: { task: HeroTask }) {
    const state = useNode(task); // subscribes to this task only
    return <li onClick={() => (state.done = !state.done)}>{state.title}</li>;
}

demo.tasks[1].subtasks[0].subtasks[0].done = true;
// ✅ that one deep row re-renders — no ancestor moves
apprenders: 1
  • renders: 1
  • renders: 1
    • renders: 1
      • renders: 1
    • renders: 1
  • renders: 1
state tree
root·1
tasks·1
tasks[0]·1tasks[1]·1
.subtasks[0]·1
.subtasks[0]·1
.subtasks[1]·1
tasks[2]·1
stats·1

// scripted mutations start in a momentauto-playing

A real Retree tree running in this page — the loop mutates it with plain assignments until you take over.

No observer() HOCs, no action wrappers

Components are plain functions; writes are plain assignments.

Subscriptions at any depth

useNode subscribes to any node in the tree — a row, a field, a whole panel — not a top-level store.

Tree operations built in

parent, move, link, clone — ownership is explicit, not a data-modeling exercise.

Class view models with decorators

@select, @memo, and @ignore on your own ReactiveNode classes, mixed freely with plain objects.

how it works

Three ideas, in adoption order.

01

Mutate with plain assignments

Pass any object to Retree.root, read a node with useNode, and assign to it like ordinary TypeScript. A component re-renders only when a node it subscribed to changes — writes to a nested child emit on that child, not on every ancestor.

useNode guide

granularity.tsx
const dashboard = Retree.root({
    header: { title: "Team dashboard" },
    stats: { views: 0 },
});

function StatsCard() {
    // Subscribes to dashboard.stats — and nothing else.
    const stats = useNode(dashboard.stats);
    return <span>{stats.views}</span>;
}

dashboard.stats.views += 1; // ✅ StatsCard re-renders
dashboard.header.title = "Ops"; // ❌ StatsCard does not re-render
<Demo /> — no subscriptionauto-playing
<HeaderCard />renders: 1

Team dashboard

useNode(dashboard.header)

<StatsCard />renders: 1

0 views

useNode(dashboard.stats)

Writes fire on their own every few seconds — click one (▶) to take over, and watch which card's counter moves:

write log — useNode(log.entries)

// writes will show up here

02

Derive values with useSelect

useSelect re-renders a component only when the selected value changes. Title edits don't touch a done-count; neither does anything else that leaves the selection equal. It can even infer dependencies from the reads inside the selector.

useSelect guide

done-count.tsx
import { Retree } from "@retreejs/core";
import { useSelect } from "@retreejs/react";

const project = Retree.root({
    tasks: [
        { title: "Docs", done: false },
        { title: "Tests", done: true },
    ],
});

function DoneCount() {
    const doneCount = useSelect(
        project.tasks,
        (tasks) => tasks.filter((task) => task.done).length,
        { listenerType: "treeChanged" }
    );

    return <span>{doneCount}</span>;
}

project.tasks[0].done = true; // ✅ re-renders DoneCount: 1 -> 2
project.tasks[0].title = "Better docs"; // ❌ no re-render: doneCount stayed 2

03

Tree semantics are built in

Every node has one structural parent, and changing that is a first-class operation — not a data-modeling exercise. Retree.move transfers ownership, Retree.link points without reparenting, Retree.clone copies, and Retree.parent lets a node operate on its own container.

Tree operations guide

tree-ops.ts
const task = board.backlog[0];

Retree.move(task, board.active); // transfer ownership
board.selected = Retree.link(task); // reactive pointer, no reparenting
board.backlog.push(Retree.clone(task)); // detached, independent copy

// The ✕ button in the demo: delete a task without knowing
// which list owns it by now — ask for its parent.
const owner = Retree.parent(task); // -> board.active
owner.splice(owner.indexOf(task), 1);
<Parent /> — no subscription

Click an operation () to run it against the tree below — then check which render counters moved.

useNode(board.backlog)renders: 1
  • Design the schemarenders: 1
  • Write the parserrenders: 1
  • Add benchmarksrenders: 1
useNode(board.active)renders: 1
  • Fix the flaky testrenders: 1

the difference

Same app. Watch the render counters.

Toggle tasks on either side. With a top-level store, the owning component re-renders on every write — React.memo keeps siblings quiet, but the owner still runs. With Retree, each row subscribes to its own node, so the parent's counter never moves.

one interaction → both implementations

A scripted loop has been mirroring writes into both stores since this page loaded — hover to pause it. Click a control below (or interact with either pane) to take over: the same change runs against both stores simultaneously, so the render counters always compare the identical interaction.

// mirrored mutations start in a momentauto-playing

idiomatic top-level store

One useState object, immutable updates, React.memo rows.

0renders

<App />renders: 1
renders: 1
  • Write the docsrenders: 1
  • Review the PRrenders: 1
  • Ship the releaserenders: 1

done: 1/3computed in <App />renders: 1

retree — useNode

Same UI, same interactions. Each row subscribes to its own node.

0renders

<App />renders: 1
renders: 1
  • Write the docsrenders: 1
  • Review the PRrenders: 1
  • Ship the releaserenders: 1

done: 1/3useSelectrenders: 1

same interactions — 0 renders with the top-level store vs 0 with Retree

Audit both panes — the comparison side is a single useState store with immutable updates and React.memo rows, not a strawman
top-level-store.tsx
// "idiomatic top-level store" pane — the exact pattern running above.
function StoreApp() {
    const [store, setStore] = useState(() => ({ tasks: initialTasks() }));

    const toggle = useCallback((id: number) => {
        setStore((previous) => ({
            ...previous,
            tasks: previous.tasks.map((task) =>
                task.id === id ? { ...task, done: !task.done } : task
            ),
        }));
    }, []);

    const doneCount = store.tasks.filter((task) => task.done).length;

    return (
        <Pane doneCount={doneCount}>
            {store.tasks.map((task) => (
                <StoreRow key={task.id} task={task} onToggle={toggle} />
            ))}
        </Pane>
    );
}

const StoreRow = React.memo(function StoreRow({ task, onToggle }) {
    return <Row done={task.done} onToggle={() => onToggle(task.id)} />;
});
retree.tsx
// "Retree useNode" pane — the exact pattern running above.
const tree = Retree.root({ tasks: initialTasks() });

function RetreeApp() {
    const tasks = useNode(tree.tasks);
    return (
        <Pane doneCount={<DoneCount />}>
            {tasks.map((task) => (
                <RetreeRow key={task.id} task={task} />
            ))}
        </Pane>
    );
}

function RetreeRow({ task }) {
    const state = useNode(task);
    return (
        <Row done={state.done} onToggle={() => (state.done = !state.done)} />
    );
}

function DoneCount() {
    const doneCount = useSelect(
        tree.tasks,
        (tasks) => tasks.filter((task) => task.done).length,
        { listenerType: "treeChanged" }
    );
    return <>{doneCount}/3</>;
}

Why Retree — the full comparison, trade-offs included →

view models

View models: ReactiveNode + decorators.

Extend ReactiveNode when a node should carry its own logic. Declared dependencies decide when the node emits, and the decorators keep renders selective while getters and methods stay on the class — designed to be used together, with useNode on the component side. Every demo below is live; the code shown is the code running.

auto-playing

dependencies: the counter subscribes to its numbers array but compares evenCount — push 2 and the count changes, so the badge re-renders; push 3 and it doesn't, so only the directly-subscribed list moves.

even-counter.tsx
import { ReactiveNode, Retree } from "@retreejs/core";

class EvenCounter extends ReactiveNode {
    public numbers: number[] = [];

    get evenCount(): number {
        return this.numbers.filter((value) => value % 2 === 0).length;
    }

    get dependencies() {
        // Subscribe to the numbers array, but emit only when the
        // compared value — evenCount — actually changes.
        return [this.dependency(this.numbers, [this.evenCount])];
    }
}

const counter = Retree.root(new EvenCounter());
const state = useNode(counter); // in the badge panel

counter.numbers.push(2); // ✅ evenCount changed — the panel re-renders
counter.numbers.push(3); // ❌ evenCount unchanged — the panel stays quiet
useNode(counter) — gated by dependenciesrenders: 1

evenCount: 0

numbers as of this panel's last render: []

useNode(counter.numbers) — direct subscriptionrenders: 1

[]

write log — useNode(log.entries)

// writes will show up here

packages

Four packages, one tree.

@retreejs/core

The tree engine: proxies, events, transactions, memoized getters, and ReactiveNode. No React required.

Pairs with any UI layer — or none.

@retreejs/react

Hooks that bind components to nodes: useRoot, useNode, useTree, useSelect, and useRaw.

Pairs with @retreejs/core.

@retreejs/convex

Convex queries, actions, mutations, and connection state written into Retree nodes — reconciled by _id, with narrow optimistic updates.

Pairs with @retreejs/core and convex.

@retreejs/react-convex

Adapts Convex's ConvexReactClient to the Retree Convex interface.

Use instead of running separate clients for Convex React hooks and Retree Convex nodes.

performance

Measured in the open.

Retree's performance argument is the architecture: subscriptions attach to individual nodes, so a write notifies the components that read that node — not your whole app. The numbers behind that claim come from an open benchmark harness in the repository, run as named workloads (transaction batching, reactive dependency fan-out, subscription setup) with absolute timings. No cherry-picked deltas on this page — clone the repo and run it on your machine.

Browse the benchmark harness →

Start with one object.

Make it a root, read it with a hook, mutate it in plain TypeScript.

npm i @retreejs/core @retreejs/react