Minimal boilerplate
Components are plain functions; writes are plain assignments. Mutate data at wherever your data lives.
React state done better.
npm create @retreejs@latestconst 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
Minimal boilerplate
Components are plain functions; writes are plain assignments. Mutate data at wherever your data lives.
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, and clone are one call each. No id tables, no lookup bookkeeping.
APIs for precise performance
@select, @memo, and @ignore on your own ReactiveNode classes, mixed freely with plain objects.
how it works
A few key concepts to help you get started.
01
Create a store with Retree.root, observe nodes with useNode, and set values like ordinary TypeScript. On the sticky board below, each note subscribes to its own node — edit one note's text or sticker and only that note's render counter moves.
const board = Retree.root({
notes: [
{ sticker: "📌", text: "Buy oat milk" },
{ sticker: "⭐", text: "Call Dana" },
{ sticker: "🌱", text: "Water plants" },
{ sticker: "✈️", text: "Book flights" },
],
});
function Sticky({ note }: { note: StickyNote }) {
const n = useNode(note); // subscribes to this note only
return <Note sticker={n.sticker} text={n.text} />;
}
board.notes[2].text = "Plant a tree"; // ✅ re-renders that sticky only
board.notes[2].sticker = "🌳"; // ✅ same note — the other three stay quietWrites fire on their own — edit a note or tap a sticker to take over. Only that note's counter moves.
// scripted writes start in a momentauto-playing
02
useSelect re-runs your selector whenever a dependency changes and re-renders only when the selected value changes. Search the inbox below — every keystroke and folder click re-runs the selector, and the list re-renders only when the results change.
import { Retree } from "@retreejs/core";
import { useSelect } from "@retreejs/react";
const mailbox = Retree.root({
search: "",
folder: "Inbox",
messages: [/* { from, subject, folder } */],
});
/** Re-render only when the results actually change. */
const sameResults = (a: Message[], b: Message[]) =>
a.length === b.length && a.every((message, i) => message === b[i]);
function Results() {
const results = useSelect(
mailbox,
(mail) =>
mail.messages.filter(
(message) =>
message.from.includes(mail.search) &&
message.folder === mail.folder
),
{ listenerType: "treeChanged", equals: sameResults }
);
return <MessageList messages={results} />;
}
mailbox.search = "ana"; // ✅ selector re-runs — results change, re-render
mailbox.folder = "Archive"; // ✅ plain writes drive the whole search UIdeps:mail.searchmail.folder
// scripted searches start in a momentauto-playing
03
Every node has one structural parent for bi-directional tree traversal. Drag a card on the kanban below — the drop is one Retree.move. Retree.link points without reparenting, Retree.clone copies, and Retree.parent returns a node's parent.
// Dropping a card is one call — ownership transfers, at the drop index.
function onDragEnd({ task, destination, index }: Drop) {
Retree.move(task, destination, index);
}
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);Drag a card between lists — the drop is one Retree.move. Then watch which render counters moved.
board.selected = null — press ⇢ on a card to link it
// drop a card to run a move
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 — or type in either list's name field — and watch the render counters bounce. With a top-level store, React.memo can help keep siblings quiet but still carries render overhead. With Retree, each component only rerenders for the precise state it uses.
See for yourself
// 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
// "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
Stop keeping your state in a top-level store referenced by many components. Instead, extend ReactiveNode and they stay on the class that owns the data: explicit dependencies decide when the node emits, decorators keep renders selective, and observe changes with useNode and useSelect.
Dependencies allow a node to observe changes from other Retree-managed nodes while filtering out irrelevant changes. In the below example, the counter subscribes to its numbers array but only emits changes when an even number is added.
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
core and react cover most apps; the other two exist for Convex users.
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. Auto 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 has a robust CLI we use to measure performance and experiment against. If you are curious to see how the benchmarks perform on your own machine, check out our CLI.
Too lazy to try it yourself? Install our agent skills and ask your agent to handle the trees while you go touch grass in the shade of a real one.
npm create @retreejs@latest