Why Retree
Retree is built on one idea. Your UI is a tree of components, and your state is a tree of objects — so a component should subscribe to the node of state it actually reads, and re-render only when that node changes.
A common React pattern instead keeps state in one top-level store and passes it down. Every update flows through the store owner, and granularity is something you add back by hand — React.memo, useCallback, immutable spread updates. Retree flips that: state is a plain TypeScript object tree, you mutate it with plain assignment, and each component picks its own subscription with useNode / useTree / useSelect / useRaw.
The payoff is largest where state runs deep. This is the write Retree is built for:
// A board, three levels deep — plain assignment:
board.columns[2].cards[0].checklist[1].done = true;
// Exactly one component re-renders: the row that called
// useNode(item) on that checklist item. No observer()
// wrapper, no snapshot juggling, no memoization pass.Don't take the claim on faith — count the renders yourself. Both implementations below are complete, unsimplified, and shown in full under "View source". The React side is written the way a careful React developer would write it, not a strawman.
One interaction, two implementations. A scripted loop has been mirroring writes into both stores since the page loaded — hover pauses it. Type in a name field or toggle a box in either pane to take over: the same change is applied to both stores at once, so the counters always compare the identical interaction.
idiomatic top-level store
0renders this session
1 of 3 donerenders: 1
component tree — live render counts
One useState store at the top; React.memo + useCallback below. Memoization keeps untouched rows quiet — but the store owner (TasksApp) re-renders on every keystroke and every toggle, because the store lives in its state.
retree — useNode
0renders this session
1 of 3 donerenders: 1
component tree — live render counts
One plain object; each component subscribes to the node it reads with useNode / useSelect. TasksApp subscribes to nothing and renders once. Typing re-renders NameInput alone.
// mirrored mutations start in a momentauto-playing
same interactions — 0 renders with the top-level store vs 0 with Retree
These are the actual modules rendered above, read from disk at build time — not simplified excerpts. The useRenderProbe lines are shared instrumentation (render counter, glow, diagram mirroring), and the mirror calls apply each interaction to both stores; both are identical on the two sides.
"use client";
/**
* The "idiomatic top-level store" side of the comparative visualizer.
*
* This is written the way a careful React developer would write it — one
* useState store at the top, immutable updates, stable callbacks via
* useCallback, and React.memo on every child — NOT a strawman. Memoization
* keeps untouched rows quiet; the store owner still re-renders on every
* update because the store lives in its state.
*
* Both implementations render side by side, and every interaction is
* mirrored: local inputs call `mirror.*`, which applies the same logical
* change to BOTH stores (this one via the setStore updaters registered
* below). The store itself stays a plain useState store.
*
* The useRenderProbe(...) lines are shared instrumentation (render counter,
* glow, diagram mirroring) and are identical on the Retree side.
*/
import { memo, useCallback, useEffect, useState } from "react";
import { RenderBadge } from "@/components/visualizer/RenderBadge";
import {
createProbeSet,
useRenderProbe,
type DemoMirror,
} from "@/components/compare/renderProbes";
export const reactProbes = createProbeSet();
interface Task {
id: number;
title: string;
done: boolean;
}
interface Store {
listName: string;
tasks: Task[];
}
const initialStore: Store = {
listName: "Launch checklist",
tasks: [
{ id: 1, title: "Write the docs", done: true },
{ id: 2, title: "Add more tests", done: false },
{ id: 3, title: "Ship the release", done: false },
],
};
const NameInput = memo(function NameInput({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const { ref, renders } = useRenderProbe<HTMLDivElement>(reactProbes.name);
return (
<div
ref={ref}
className="flex items-center gap-2 rounded-md border border-border-token bg-surface px-3 py-2"
>
<label
htmlFor="react-demo-list-name"
className="font-mono text-[11px] uppercase tracking-widest text-faint"
>
Name
</label>
<input
id="react-demo-list-name"
value={value}
onChange={(event) => onChange(event.target.value)}
className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none"
/>
<RenderBadge renders={renders} />
</div>
);
});
const TaskRow = memo(function TaskRow({
task,
onToggle,
}: {
task: Task;
onToggle: (id: number) => void;
}) {
const { ref, renders } = useRenderProbe<HTMLLIElement>(
reactProbes.rows[task.id - 1]
);
return (
<li
ref={ref}
className="flex items-center gap-2 rounded-md border border-border-token bg-surface px-3 py-2"
>
<input
id={`react-demo-task-${task.id}`}
type="checkbox"
checked={task.done}
onChange={() => onToggle(task.id)}
className="accent-[var(--accent-glow)]"
/>
<label
htmlFor={`react-demo-task-${task.id}`}
className="flex-1 cursor-pointer text-sm text-foreground"
>
{task.title}
</label>
<RenderBadge renders={renders} />
</li>
);
});
const DoneCount = memo(function DoneCount({ tasks }: { tasks: Task[] }) {
const { ref, renders } = useRenderProbe<HTMLParagraphElement>(
reactProbes.done
);
const doneCount = tasks.filter((task) => task.done).length;
return (
<p
ref={ref}
className="flex items-center gap-2 rounded-md border border-border-token bg-surface px-3 py-2 text-sm text-muted"
>
<span className="flex-1">
{doneCount} of {tasks.length} done
</span>
<RenderBadge renders={renders} />
</p>
);
});
export function ReactTasksDemo({ mirror }: { mirror: DemoMirror }) {
const [store, setStore] = useState(initialStore);
const { ref, renders } = useRenderProbe<HTMLDivElement>(reactProbes.app);
// The two write paths of this store — ordinary useState updaters.
const applyListName = useCallback((listName: string) => {
setStore((current) => ({ ...current, listName }));
}, []);
const applyToggle = useCallback((id: number) => {
setStore((current) => ({
...current,
tasks: current.tasks.map((task) =>
task.id === id ? { ...task, done: !task.done } : task
),
}));
}, []);
// Register the write paths so interactions started in EITHER pane land
// in this store too — that keeps the two implementations comparable.
useEffect(() => {
return mirror.register("react", {
setListName: applyListName,
toggleTask: applyToggle,
});
}, [mirror, applyListName, applyToggle]);
// Local inputs go through the mirror so the other pane sees them too.
const setListName = useCallback(
(listName: string) => mirror.setListName(listName),
[mirror]
);
const toggleTask = useCallback(
(id: number) => mirror.toggleTask(id),
[mirror]
);
return (
<div ref={ref} className="space-y-2 rounded-lg p-1">
<header className="flex items-center gap-2 px-1">
<span className="flex-1 truncate text-sm font-semibold text-foreground">
TasksApp
</span>
<RenderBadge renders={renders} />
</header>
<NameInput value={store.listName} onChange={setListName} />
<ul className="space-y-2">
{store.tasks.map((task) => (
<TaskRow key={task.id} task={task} onToggle={toggleTask} />
))}
</ul>
<DoneCount tasks={store.tasks} />
</div>
);
}"use client";
/**
* The "Retree useNode" side of the comparative visualizer.
*
* Same UI as the React side, same instrumentation. State is one plain
* object; each component subscribes to exactly the node it reads, so no
* React.memo or useCallback is needed — the app component never re-renders
* because it subscribes to nothing.
*
* Both implementations render side by side, and every interaction is
* mirrored: local inputs call `mirror.*`, which applies the same logical
* change to BOTH stores (this one via the plain assignments registered
* below).
*
* The task set is fixed in this demo. A list that adds or removes rows
* would subscribe with useNode(list.tasks) in the component that maps them.
*
* The useRenderProbe(...) lines are shared instrumentation (render counter,
* glow, diagram mirroring) and are identical on the React side.
*/
import { useEffect } from "react";
import { useNode, useRoot, useSelect } from "@retreejs/react";
import { RenderBadge } from "@/components/visualizer/RenderBadge";
import {
createProbeSet,
useRenderProbe,
type DemoMirror,
} from "@/components/compare/renderProbes";
export const retreeProbes = createProbeSet();
interface Task {
id: number;
title: string;
done: boolean;
}
interface TaskList {
name: string;
tasks: Task[];
}
function createInitialList(): TaskList {
return {
name: "Launch checklist",
tasks: [
{ id: 1, title: "Write the docs", done: true },
{ id: 2, title: "Add more tests", done: false },
{ id: 3, title: "Ship the release", done: false },
],
};
}
function NameInput({ list, mirror }: { list: TaskList; mirror: DemoMirror }) {
// Subscribes to the list node: re-renders on name changes only.
const state = useNode(list);
const { ref, renders } = useRenderProbe<HTMLDivElement>(retreeProbes.name);
return (
<div
ref={ref}
className="flex items-center gap-2 rounded-md border border-border-token bg-surface px-3 py-2"
>
<label
htmlFor="retree-demo-list-name"
className="font-mono text-[11px] uppercase tracking-widest text-faint"
>
Name
</label>
<input
id="retree-demo-list-name"
value={state.name}
onChange={(event) => mirror.setListName(event.target.value)}
className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none"
/>
<RenderBadge renders={renders} />
</div>
);
}
function TaskRow({ task, mirror }: { task: Task; mirror: DemoMirror }) {
// Subscribes to this task node: re-renders only when this row changes.
const state = useNode(task);
const { ref, renders } = useRenderProbe<HTMLLIElement>(
retreeProbes.rows[state.id - 1]
);
return (
<li
ref={ref}
className="flex items-center gap-2 rounded-md border border-border-token bg-surface px-3 py-2"
>
<input
id={`retree-demo-task-${state.id}`}
type="checkbox"
checked={state.done}
onChange={() => mirror.toggleTask(state.id)}
className="accent-[var(--accent-glow)]"
/>
<label
htmlFor={`retree-demo-task-${state.id}`}
className="flex-1 cursor-pointer text-sm text-foreground"
>
{state.title}
</label>
<RenderBadge renders={renders} />
</li>
);
}
function DoneCount({ list }: { list: TaskList }) {
// Subscribes to a projection: re-renders only when the count changes.
const doneCount = useSelect(
list.tasks,
(tasks) => tasks.filter((task) => task.done).length,
{ listenerType: "treeChanged" }
);
const { ref, renders } = useRenderProbe<HTMLParagraphElement>(
retreeProbes.done
);
return (
<p
ref={ref}
className="flex items-center gap-2 rounded-md border border-border-token bg-surface px-3 py-2 text-sm text-muted"
>
<span className="flex-1">
{doneCount} of {list.tasks.length} done
</span>
<RenderBadge renders={renders} />
</p>
);
}
export function RetreeTasksDemo({ mirror }: { mirror: DemoMirror }) {
// Creates the root once for this component's lifetime. The app
// subscribes to nothing, so it renders exactly once.
const list = useRoot(createInitialList);
const { ref, renders } = useRenderProbe<HTMLDivElement>(retreeProbes.app);
// Register this tree's write paths — plain assignments — so
// interactions started in EITHER pane land in this tree too.
useEffect(() => {
return mirror.register("retree", {
setListName: (name) => {
list.name = name;
},
toggleTask: (id) => {
const task = list.tasks.find((entry) => entry.id === id);
if (task === undefined) {
throw new Error(
`RetreeTasksDemo: no task with id ${id} exists in the demo tree to toggle.`
);
}
task.done = !task.done;
},
});
}, [mirror, list]);
return (
<div ref={ref} className="space-y-2 rounded-lg p-1">
<header className="flex items-center gap-2 px-1">
<span className="flex-1 truncate text-sm font-semibold text-foreground">
TasksApp
</span>
<RenderBadge renders={renders} />
</header>
<NameInput list={list} mirror={mirror} />
<ul className="space-y-2">
{list.tasks.map((task) => (
<TaskRow key={task.id} task={task} mirror={mirror} />
))}
</ul>
<DoneCount list={list} />
</div>
);
}One table, verified against MobX 6.16 and Valtio 2.3 in July 2026 — including the rows where Retree is behind. Pairwise write-ups: Retree vs MobX and Retree vs Valtio.
| Feature | Retree@retreejs/core + react 0.5.1 | MobX6.16 + mobx-react-lite 4.1 | Valtio2.3 |
|---|---|---|---|
| Mutation model | First-class:Plain assignment on plain objects and classes | First-class:Mutable observables; writes wrapped in action1 | First-class:Mutate proxy state; render from a frozen snap2 |
| Subscription granularity | First-class:Per node — you pick with useNode / useTree / useSelect / useRaw | First-class:Reads tracked inside observer()3 | First-class:Access-tracked snapshots2 |
| Nested stores that match the component tree | First-class:The core idea — subscribe to any node in one tree | Possible with work:Top-level store orientation4 | Possible with work:Top-level store orientation4 |
| Hooks without HOC wrappers | First-class:Hooks only | Absent:observer() required on every reading component3 | First-class:useSnapshot hook |
| Class instances as state | First-class:Classes and plain objects, no registration call | First-class:makeAutoObservable(this) | Not scored:Not verified5 |
| Optional decorators | First-class:Standard 2023-11 decorators: @select, @memo, @ignore, @link — always optional | Not scored:Not verified5 | Not scored:Not verified5 |
| Computed / memoized values | First-class:memo, @memo, @fnMemo, @select | First-class:Best-in-class computed engine6 | Possible with work:Getters are uncached and siblings-only2 |
| Transactions & silent writes | First-class:Retree.runTransaction, Retree.runSilent | Not scored:Not verified5 | Not scored:Not verified5 |
| Tree operations: parent / move / clone / link | First-class:Built in: Retree.parent, move, clone, link | Absent:Not in core — defers to mobx-state-tree4 | Absent:No tree semantics4 |
| Escape hatches for hot paths | First-class:Retree.raw, useRaw, peekInto, untracked | Not scored:Not verified5 | Not scored:Not verified5 |
| First-party backend integration | First-class:@retreejs/convex (Convex)7 | Absent:No official backend integration7 | Absent:No official backend integration7 |
| DevtoolsRetree trails here | Absent:None today8 | Not scored:Not verified5 | First-class:Redux DevTools |
| Concurrent React (useSyncExternalStore) | Possible with work:useState + useEffect subscriptions; no useSyncExternalStore yet9 | Not scored:Not verified5 | Not scored:Not verified5 |
| TypeScript inference | First-class:Hooks return your object's own type | Not scored:Not verified5 | Not scored:Not verified5 |
| Bundle size (min+gzip, measured by us) | Measured value:18.1 kB (core + react)10 | Measured value:21.5 kB (mobx + mobx-react-lite)10 | Measured value:2.8 kB10 |
| Ecosystem & community resourcesRetree trails here | Absent:Young — this site and the repo are the resources today11 | First-class:Established | First-class:Established |
| Production track recordRetree trails here | Absent:v0.5.x — no known large deployments11 | First-class:A decade of hardening6 | First-class:Established |
| Framework-agnostic usage beyond ReactRetree trails here | Possible with work:Core runs anywhere; React is the only first-class binding12 | First-class:Yes | First-class:Yes |
| Redux DevTools supportRetree trails here | Absent:No8 | Not scored:Not verified5 | First-class:Yes |
Accuracy pledge
Comparison tables rot. Every claim above was verified against the listed versions in July 2026, and cells we could not verify are left unscored rather than guessed. Bundle sizes are our own measurements: esbuild, minified + gzip, react and react-dom externalized, July 2026, same method for every library. If anything here is wrong, stale, or unfair to another library, open an issue or submit a PR — corrections are merged gladly.
What about mobx-state-tree?
mobx-state-tree is the closest competitor to Retree on tree semantics, and it deserves the mention. It is a separate, schema-based layer over MobX: you define typed models up front and get a managed state tree in return. Retree aims at the same tree-shaped problems without the schema layer — nodes are your own plain objects and class instances, typed by TypeScript inference rather than a runtime model definition, and components subscribe through hooks instead of observer(). If you want runtime-enforced schemas for your tree, mobx-state-tree is the established choice; if you want the tree without the schemas, that is exactly the gap Retree exists to fill.
MobX has a decade of production hardening and a best-in-class computed engine — on derived data, it sets the bar. The price is observer()around every reading component and actions around writes. Retree's bet is that you can keep the mutable model and drop both: hooks pick the subscription, plain assignment does the writing.
Valtio is 2.8 kB min+gzip in our measurements to Retree's 18.1 kB for core + react, and it ships Redux DevTools support, which Retree does not yet. What Retree's bytes buy: per-node subscriptions, tree operations (parent / move / clone / link), view models with optional decorators, transactions, and a first-party Convex integration — all in one object, with no state/snapshot split.
For a small, flat store — a handful of top-level fields, no nesting — Zustand (0.4 kB in the same measurement) covers it; Retree earns its bytes when your state is genuinely a tree.
The questions a skeptical senior engineer should ask, answered without spin. Every trade here is deliberate.
Retree's hooks (useNode, useTree, useSelect, useRaw) subscribe with useState + useEffect, not useSyncExternalStore — you can read that directly in the hook source. Updates arrive through React's normal state queue after a commit-phase subscription. We have not validated behavior under heavy concurrent rendering (useTransition, Suspense-driven interruptions), and we won't claim tearing-safety we haven't tested. What you get in exchange for that missing checkbox: a subscription core small enough to audit in an afternoon — if your app leans hard on concurrent features, run it against your workload and read exactly what it does.
This site itself runs with reactCompiler: truein its Next.js config. Retree's hooks are annotated with "use no memo", so the compiler skips memoizing the hooks' own internals instead of mis-optimizing their subscription mechanics; your compiled components call them normally. It is an opt-out, not a deep integration — and it works: this compiled site dogfoods Retree's hooks for its own state.
Per-node subscriptions are explicit. useNode(project) re-renders for changes to project's own fields — not for a write to project.tasks[0].text; the component reading that task needs useNode(task). This is the model working as designed — granularity you can see — but it is a real thing to learn, and getting it wrong means a stale view. Common pitfalls walks through every known trap.
Retree is v0.5.x, MIT-licensed, and built by a solo maintainer. It is pre-1.0: minor versions may move APIs, and the ecosystem of tutorials and Stack Overflow answers is still growing. What you can audit today instead of taking on faith: the test suite lives beside the source in every package, and the benchmark harness and findings are open. Pre-1.0 cuts both ways: the surface area is small enough to read in a sitting, and issues you file go straight to the person who wrote the code. Evaluate the code, not the star count.
The quickstart gets you from install to a working app — mutate a plain object, watch exactly one component re-render.