retree
DocsAPIWhy Retree

Start here

  • Quickstart
  • Thinking in Retree
  • Common pitfalls

React

  • Choosing a hook
  • useRoot
  • useNode
  • useTree
  • useSelect
  • useRaw

Core

  • Events & subscriptions
  • Effects & reactions
  • Tree operations
  • Transactions & silent writes
  • Undo & redo

View models

  • ReactiveNode & decorators
  • Setup & decorators

Going deeper

  • Select semantics
  • Performance
  • React Compiler
  • Testing
  • DevTools
  • Convex integration
  • Async queries
  • Compatibility

Migrate

  • From MobX
  • From Zustand
  • From Redux Toolkit

Start here

  • Quickstart
  • Thinking in Retree
  • Common pitfalls

React

  • Choosing a hook
  • useRoot
  • useNode
  • useTree
  • useSelect
  • useRaw

Core

  • Events & subscriptions
  • Effects & reactions
  • Tree operations
  • Transactions & silent writes
  • Undo & redo

View models

  • ReactiveNode & decorators
  • Setup & decorators

Going deeper

  • Select semantics
  • Performance
  • React Compiler
  • Testing
  • DevTools
  • Convex integration
  • Async queries
  • Compatibility

Migrate

  • From MobX
  • From Zustand
  • From Redux Toolkit
Loading page…

retree

Reactive object trees for React. MIT licensed.

© 2026 Ryan Bliss

Docs

  • Quickstart
  • Thinking in Retree
  • React hooks
  • Common pitfalls

Reference

  • @retreejs/core
  • @retreejs/query
  • @retreejs/react
  • @retreejs/devtools
  • @retreejs/convex
  • @retreejs/react-convex

Project

  • Why Retree
  • GitHub
  • npm
  • llms.txt

Docs

Edit on GitHub

From Zustand

A concept-by-concept map from Zustand to Retree — create/set/get, selectors, slices, and middleware — with a before/after store and the gotchas.

Zustand and Retree agree on a lot: no provider, no reducers, state defined in plain code and used through hooks. The shift is the update model — Zustand replaces state with set(...), Retree mutates one long-lived tree directly — and re-render granularity, which Zustand gets from selector equality and Retree gets from per-node subscriptions. This page is the routing table, not a tutorial; the Quickstart and Thinking in Retree cover the destination properly.

Concept map#

ZustandRetree
create((set, get) => ({ ... }))Retree.root({ ... }) — a plain object or class instance
useStore()useNode(node) — per node, not per store
useStore((s) => s.doneCount)useSelect(() => node.doneCount)
set({ count: 1 }) / set(fn)node.count = 1 — plain assignment
get().countnode.count — plain reads, anywhere
Actions defined inside the storeMethods on classes in the tree, or plain functions that mutate it
Slices patternChild nodes — one object per domain under the root
subscribe / subscribeWithSelectorRetree.on / Retree.select
immer middlewareNot needed — mutation is the model
persist middlewareNone today — Retree.raw(node) gives a serializable snapshot to save yourself
devtools middleware@retreejs/devtools — connectReduxDevTools()

Before and after#

A tasks store with an action and a derived count:

Zustand
import { create } from "zustand";

interface TaskState {
    tasks: { id: number; text: string; done: boolean }[];
    toggle: (id: number) => void;
}

const useTaskStore = create<TaskState>((set) => ({
    tasks: [{ id: 1, text: "Write docs", done: false }],
    toggle: (id) =>
        set((state) => ({
            tasks: state.tasks.map((task) =>
                task.id === id ? { ...task, done: !task.done } : task
            ),
        })),
}));

function DoneBadge() {
    // Selector equality decides when this re-renders.
    const doneCount = useTaskStore(
        (state) => state.tasks.filter((task) => task.done).length
    );
    return <span>{doneCount}</span>;
}
Retree
import { Retree } from "@retreejs/core";
import { useNode, useSelect } from "@retreejs/react";

const taskStore = Retree.root({
    tasks: [{ id: 1, text: "Write docs", done: false }],
});

function toggle(id: number) {
    const task = taskStore.tasks.find((task) => task.id === id);
    if (task) task.done = !task.done; // no spread, no map
}

function DoneBadge() {
    // Subscribes to the reads the selector performs.
    const doneCount = useSelect(
        () => taskStore.tasks.filter((task) => task.done).length
    );
    return <span>{doneCount}</span>;
}

function TaskRow({ task }: { task: (typeof taskStore.tasks)[number] }) {
    // Per-node subscription: other tasks' changes never render this row.
    const state = useNode(task);
    return (
        <label>
            <input
                type="checkbox"
                checked={state.done}
                onChange={() => toggle(state.id)}
            />
            {state.text}
        </label>
    );
}

The selector hook translates almost one-to-one. What has no Zustand equivalent is TaskRow: subscribing a component to one item directly, without selector plumbing — that granularity is the main thing you gain.

Gotchas#

  • Mutate, don't rebuild. The map-and-spread idiom still works in Retree (assigning a new array is a write like any other), but it defeats per-node granularity — replacing the array touches every row. Reach for direct mutation first.
  • Subscription follows the hook, not the selector. useNode(node) re-renders for that node's own fields only; each child a component renders needs its own subscription. The rule and its fix are in Common pitfalls.
  • Identity works differently. Zustand gives you new object identities on every update; Retree nodes reproxy on change, so React.memo and dependency arrays work with nodes — but not with raw values.
  • No middleware system. Cross-cutting concerns hang off Retree.on subscriptions instead of a middleware chain. devtools has a first-party equivalent (@retreejs/devtools); persist does not — save Retree.raw(node) snapshots yourself, or replay change records with Retree.applyChanges.
  • Classes are welcome but optional. Everything above is plain objects and functions. When a domain accumulates derived state, graduate it to a ReactiveNode — there's no equivalent step to graduate to in Zustand.

Where next#

  • Choosing a hook — the granularity options replacing selector-equality tuning.
  • Select semantics — what useSelect does and doesn't do.
  • Why Retree — the live re-render comparison.
← PreviousFrom MobXNext →From Redux Toolkit

On this page

  • Concept map
  • Before and after
  • Gotchas
  • Where next