Simple state framework for precise, lightning-fast reactive rendering.

React state done better.

npm create @retreejs@latest
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

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

Reactive state that just works

A few key concepts to help you get started.

01

Mutate with plain assignments

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.

useNode guide

sticky.tsx
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 quiet
sticky board · live4 notes
renders: 1
renders: 1
renders: 1
renders: 1

Writes 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

Query your state with useSelect

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.

useSelect guide

inbox.tsx
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 UI
mail · useSelectselector ran: 0
3 results
<Results /> — useSelectrenders: 1
  • Ana WoodsQuarterly growth reportInbox
  • Anders LindDesign review notesInbox
  • Elle PineStandup notesInbox

deps:mail.searchmail.folder

// scripted searches start in a momentauto-playing

03

Tree semantics are built in

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.

Tree operations guide

tree-ops.ts
// 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);
kanban · tree ops

Drag a card between lists — the drop is one Retree.move. Then watch 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
  • Ship v1.0renders: 1

board.selected = null — press ⇢ on a card to link it

// drop a card to run a move

the difference

Less manual optimization, better results.

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

<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

Compare the code for each example.
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

Enjoy the benefits of functional React while keeping state where it belongs.

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.

auto-playing

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.

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 Retree packages walk into a bar...

core and react cover most apps; the other two exist for Convex users.

@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. Auto 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

Transparent performance benchmarks

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.

Review benchmarks →

Easy to get started

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