No observer() HOCs, no action wrappers
Components are plain functions; writes are plain assignments.
Mutate a plain TypeScript object; exactly the components that read it re-render.
npm i @retreejs/core @retreejs/reactconst 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// 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
01
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.
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-renderTeam dashboard
useNode(dashboard.header)
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
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.
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 203
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.
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);Click an operation () to run it against the tree below — then check which render counters moved.
Events outside React
Retree.on subscribes to nodeChanged, treeChanged, or nodeRemoved from any code — integrations don't need hooks.
Transactions & silent writes
Retree.runTransaction batches writes into one flush; Retree.runSilent skips emission entirely.
Raw escape hatches
Retree.raw, useRaw, peekInto, and untracked for native-speed, proxy-free reads.
the difference
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
done: 1/3computed in <App />renders: 1
retree — useNode
Same UI, same interactions. Each row subscribes to its own node.
0renders
done: 1/3useSelectrenders: 1
same interactions — 0 renders with the top-level store vs 0 with Retree
useState store with immutable updates and React.memo rows, not a strawman// "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 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</>;
}view models
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.
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.
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 quietevenCount: 0
numbers as of this panel's last render: []
[]
write log — useNode(log.entries)
// writes will show up here
packages
The tree engine: proxies, events, transactions, memoized getters, and ReactiveNode. No React required.
Pairs with any UI layer — or none.
Hooks that bind components to nodes: useRoot, useNode, useTree, useSelect, and useRaw.
Pairs with @retreejs/core.
Convex queries, actions, mutations, and connection state written into Retree nodes — reconciled by _id, with narrow optimistic updates.
Pairs with @retreejs/core and 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
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.
Make it a root, read it with a hook, mutate it in plain TypeScript.
npm i @retreejs/core @retreejs/react