Retree is a lightweight and simple state management library, designed primarily for React. If you know how to work with objects in JavaScript or TypeScript, you pretty much already know how to use Retree.
Generate the TypeDoc site locally with:
npm run docsThe generated static site is written to docs/ and ignored by Git. The docs build also copies llms.txt into the generated site so agents can discover the curated docs manifest at the Pages root. GitHub Pages can host it from the included docs workflow after the repository's Pages source is set to GitHub Actions.
@retreejs/coreprovides Retree's proxy, event, memo, andReactiveNodeprimitives.@retreejs/reactprovides React hooks for rendering Retree nodes.@retreejs/convexconnects Convex queries, paginated queries, actions, mutations, and connection state to Retree nodes.
Retree.rootmakes one object the root of a Retree-managed tree. Use it once where plain state enters Retree.useRootcreates one Retree root for a React component lifetime. Use it when state belongs to a React subtree.useNodere-renders a React component for directnodeChangedevents on one node. Use it for rows, panels, forms, and focused child components.useTreere-renders fortreeChangedevents from a node or any descendant. Use it sparingly for small subtrees that truly need broad invalidation.useSelectre-renders only when a selected value or ordered dependency list changes. Use it for counts, totals, booleans, and other narrow projections.Retree.onsubscribes tonodeChanged,treeChanged, ornodeRemoved. Use it outside React and inside integrations.Retree.selectis the non-React version ofuseSelect. Use it to narrow notifications; it is not a cache.Retree.parentreturns the structural parent of a node. Use it for tree-local operations like deleting yourself from a list.Retree.movetransfers an existing node to a new structural parent. Use it when ownership should change.Retree.linkand@linkstore a reactive pointer without reparenting. Use them for selected items and cross-references.Retree.clonemakes a detached copy. Use it when two places need independent state.@selectdecorates a getter with an ordered dependency list so VM logic can stay in the node whileuseNode(node)stays selective.ReactiveNode.dependenciesmakes one node emit when another node changes. Return raw reactive nodes/primitives directly, or wrap one slot withthis.dependency(node, comparisons).memo,@memo, and@fnMemocache computed values. Prefer bare decorators for automatic dependency trapping; pass comparison functions for finer cache-key control.@ignorekeeps a field out of Retree emissions. Use it for caches, subscriptions, framework handles, and non-rendered state.Retree.runTransactionbatches synchronous writes into one listener flush per changed node.Retree.runSilentperforms writes without emitting listeners.ReactiveNode.prepareTreewarms lazy child proxies during a controlled phase.
Retree React enables a performant, intuitive interface for managing app state of any complexity. It is designed to seamlessly mix-and-match class-based data layers with React hooks with minimal boilerplate.
Install with npm:
npm i @retreejs/core @retreejs/reactInstall with yarn:
yarn add @retreejs/core @retreejs/reactIt's extremely easy to get started with Retree. The main React hooks are useNode, useTree, useSelect, and useRoot. Each has specific advantages while leveraging the same simple interface.
Use useRoot when a component should create and retain its own Retree root. The factory runs once for the component lifetime.
import { useNode, useRoot } from "@retreejs/react";
function CounterPanel() {
const counter = useRoot(() => ({ count: 0 }));
const state = useNode(counter);
return <button onClick={() => (state.count += 1)}>{state.count}</button>;
}useRoot creates the root; useNode, useTree, or useSelect decide what causes the component to re-render.
If you adopt the useNode pattern, your apps will automatically inherit performant re-renders, since only the components that depend on each node in your object tree will re-render on changes. For this to work, you need to do the following:
- Pass some object into
Retree.root, e.g.,const root = Retree.root({ foo: "bar", list: [] }) - Make the response stateful using
useNode, e.g.,const rootState = useNode(root) - Render values from the object in your component, e.g.,
<h1>{fooState.foo}</h1> - Set values like you normally would in JS/TS, e.g.,
fooState.foo = "moo" - Ensure child nodes are passed to
useNodewhen using deeply nested values, e.g.,const list = useNode(root.list)
NOTE: A node is any non-primitive type, including objects, lists, maps, etc. Primitive values of a node like string, number, and boolean do not require being passed into useNode.
Let's take a look at a standard todo list example:
import React from "react";
import { Retree } from "@retreejs/core";
import { useNode } from "@retreejs/react";
// Todo view model
class Todo {
public text = "";
public checked = false;
toggle() {
this.checked = !this.checked;
}
onValueChange(event: React.ChangeEvent<HTMLInputElement>) {
this.text = event.target.value;
}
}
// Todo React component that accepts a Todo object as a prop
function _ViewTodo({ todo }) {
// Make todo stateful. Changes to todo will only re-render this component.
const _todo = useNode(todo);
return (
<div>
<input
type="checkbox"
checked={_todo.checked}
onChange={_todo.toggle}
/>
<input value={_todo.text} onChange={_todo.onValueChange} />
</div>
);
}
const ViewTodo = React.memo(_ViewTodo);
// Todo list view model
class TodoList {
public readonly todos: Todo[] = [];
add() {
this.todos.push(new Todo());
}
}
// Create your root TreeNode instance with any object
const root = Retree.root(new TodoList());
// Render app
function App() {
// Make our list of todos stateful
const todos = useNode(root.todos);
return (
<div>
<button onClick={root.add}>Add</button>
{todos.map((todo, index) => (
<ViewTodo key={index} todo={todo} />
))}
</div>
);
}
export default App;To better understand the rules of useNode, let's look at the following:
import React from "react";
import { Retree } from "@retreejs/core";
import { useNode } from "@retreejs/react";
const whiteboardRoot = Retree.root({
selectedColor: "red",
visible: false,
canvasSize: { width: "0px", height: "0px" },
shapes: [],
});
function App() {
const whiteboard = useNode(whiteboardRoot);
// ...
return <>{JSON.stringify(whiteboard)}</>;
}
// ✅ will re-render
whiteboardRoot.selectedColor = "blue";
// ✅ will re-render
whiteboardRoot.visible = true;
// ✅ will re-render
whiteboardRoot.canvasSize = { width: "100px", height: "100px" };
// ❌ no re-render
whiteboardRoot.canvasSize.width = "200px";
// ❌ no re-render
whiteboardRoot.shapes.push({ type: "circle" });There are two ways to fix this. The first way is to pass each child object used in a component into useNode, like this:
function App() {
const whiteboard = useNode(whiteboardRoot);
const canvasSize = useNode(whiteboard.canvasSize);
const shapes = useNode(whiteboard.shapes);
// ...
return <>{JSON.stringify(whiteboard)}</>;
}
// ✅ will re-render
whiteboardRoot.selectedColor = "blue";
// ✅ will re-render
whiteboardRoot.visible = true;
// ✅ will re-render
whiteboardRoot.canvasSize = { width: "100px", height: "100px" };
// ✅ will re-render
whiteboardRoot.canvasSize.width = "200px";
// ✅ will re-render
whiteboardRoot.shapes.push({ type: "circle" });This is ideal in cases when you want to use child nodes as props into other child components, such as a <ViewTodo todo={todo} />. This ensures that state changes to each individual item in the list won't trigger re-renders of its parent. When using memo components or the new React compiler, this also means irrelevant changes to parent nodes won't re-render items in the list.
Use useSelect when a component needs a selected value or ordered dependency list from a Retree node and should only re-render when that selection changes. It listens to nodeChanged by default; pass listenerType: "treeChanged" when the selector reads descendants.
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 2useSelect can also infer dependencies when you pass only a selector function. Whole Retree-managed values read by the selector subscribe automatically. Property reads subscribe to the owner node but compare the specific property value, so task.done reacts to task replacement or done changes without reacting to unrelated task fields. Primitive reads compare.
const doneCount = useSelect(
() => project.tasks.filter((task) => task.done).length
);Selectors can also return ordered dependency lists. Reactive entries subscribe; primitive entries compare:
const [, , attribute] = useSelect(row, (self) => [
self.attributes,
self.attributeId,
self.attribute,
]);Dependency-list subscriptions in useSelect are observational: selected dependency changes can re-render the component, but they do not force the node passed to useSelect to receive a fresh reproxy. Use @select when a ReactiveNode owner should emit nodeChanged.
useSelect is a subscription primitive, not a memo cache. Use memo, @memo, or @fnMemo for expensive computation you want to cache, then select the cached value when you want narrower renders.
In some cases it might be desirable to get re-renders for all child nodes at a given point in your object tree. In such cases, it can be impractical to put each child node in useNode. Fortunately, useTree makes this very simple.
Let's look at this simple example:
import React from "react";
import { Retree } from "@retreejs/core";
import { useNode, useTree } from "@retreejs/react";
const table = Retree.root({
headers: [{ title: "label" }, { title: "count" }, { title: "actions" }],
rows: [
{ label: "count 1", count: 0 },
{ label: "count 2", count: 0 },
],
});
function Headers({ headers }) {
// If it is cheap to render all columns, `useTree` can save time
const headerState = useTree(headers);
return (
<tr>
{headerState.map((header) => (
<td key={header.title}>{header.title}</td>
))}
</tr>
);
}
function Row({ row }) {
// In this simple case, `useNode` and `useTree` can be used interchangeably.
const rowState = useNode(row);
return (
<tr>
<td>{rowState.label}</td>
<td>{rowState.count}</td>
<td onClick={() => (rowState.count += 1)}>+1</td>
</tr>
);
}
function TotalRow({ rows }) {
// We want a sum of all rows, so we want to re-render on all child changes
const rowsState = useTree(rows);
const sumOfCounts = rowsState.reduce(
(sum, current) => sum + current.count,
0
);
return (
<tr>
<td>{rows.length}</td>
<td>{sumOfCounts}</td>
<td>N/A</td>
</tr>
);
}
function App() {
// We don't want to re-render the whole table on each state change, so we useNode
const tableState = useNode(table);
const rows = useNode(tableState.rows);
return (
<table>
<Headers headers={tableState.headers} />
{rows.map((row, i) => (
<Row key={i} row={row} />
))}
<TotalRow rows={rows} />
</table>
);
}
export default App;useTree is very powerful and makes things incredibly simple. The following scenarios should help clarify the behavior of useTree:
const root = Retree.root({
great_grandparent_1: {
name: "Bob Sr",
grandparent_1: {
name: "Bob Jr",
parent_1: {
name: "Angie",
child_1: {
name: "Megan",
},
},
},
grandparent_2: {
/** ... **/
},
},
great_grandparent_2: {
/** ... **/
},
});
// Root component
const family = useNode(root);
// Great Grandparent Component 1
const greatGrandparent1 = useTree(family.great_grandparent_1);
// Great Grandparent Component 2
const greatGrandparent2 = useTree(family.great_grandparent_2);
// If we set:
greatGrandparent1.grandparent_1.name = "Beth";
// What will NOT change:
// - Root component (no render)
// - Great Grandparent Component 2 (no render)
// - old `family` value to be unchanged in comparisons (e.g., `memo` or hook dependencies)
// - old `greatGrandparent2` + all children nodes to be unchanged in comparisons
// - old `greatGrandparent1.grandparent_1.parent_1` to be unchanged in comparisons
// - old `greatGrandparent1.grandparent_2` to be unchanged in comparisons
// What will change:
// - Great Grandparent Component 1 to render
// - old `greatGrandparent1` to not equal new `greatGrandparent1` value in comparisons
// - old `greatGrandparent1.grandparent_1` to not equal new value in comparisonsWhile useTree is powerful and can make things a lot easier, it is important to ensure its usage doesn't have negative performance. As your component tree gets more complicated, you should take care to only useTree sparingly (e.g., lower down in your view tree hierarchy).
Tip: Always use React Dev Tools' profile tab to measure render performance when using useTree.
Retree performs best when components subscribe to the narrowest node or value they need:
- Prefer
useNode(child)for item rows and focused panels. - Prefer
useSelect(node, selector)for selected values or dependency lists that should only re-render when the selection changes. - Treat
useTree/treeChangedas broad subtree invalidation, especially in hot paths. - Keep
ReactiveNode.dependenciesdeterministic. Length/order can change; Retree treats shape changes as invalidation and refreshes subscriptions. - Prefer
@selectfor hot filtered lists where one getter should listen to a broad collection but only emit when the selected items or selected order changes. - Avoid constructing large Retree roots or
ReactiveNodegraphs during React render; create them once, or initialize them throughuseMemo/useState.
Large ReactiveNode object and array fields are prepared lazily. This improves initial setup, but the first nested read pays the preparation cost. Use node.prepareTree({ depth }) or super({ prepare: { autoPrepare: true, depth } }) if you want to pay that cost during a controlled loading phase.
Recent stable medium benchmark runs show the main direction of the architecture work: runTransaction average time dropped from about 3.881 ms to 1.350 ms, and Reactive dependency fan-out average time dropped from about 2.049 ms to 0.290 ms. Setup P95 for direct nodeChanged dropped from about 6.146 ms to 1.562 ms.
Retree offers useful utility APIs for further optimizing performance, including ReactiveNode, Retree.runTransaction, and Retree.runSilent.
The ReactiveNode class allows nodes in your tree to reactively update when their declared dependencies change. This offers a middleground between useTree and useNode that can be extremely powerful for minimizing re-renders in your application.
Dependency arrays accept raw reactive nodes and primitives. Reactive nodes subscribe; primitives compare. Use this.dependency(node, comparisons) when one slot needs custom comparison values.
import { Retree, ReactiveNode, memo, select } from "@retreejs/core";
import { useNode } from "@retreejs/react";
class EvenCounter extends ReactiveNode {
public numbers: number[] = [];
get evenNumberCount(): number {
return this.numbers.filter((number) => number % 2 === 0).length;
}
get dependencies() {
return [this.dependency(this.numbers, [this.evenNumberCount])];
}
}
const counter = Retree.root(new EvenCounter());
function EvenBadge() {
const state = useNode(counter);
return <span>{state.evenNumberCount}</span>;
}
counter.numbers.push(2); // ✅ re-renders: evenNumberCount 0 -> 1
counter.numbers.push(3); // ❌ no re-render: evenNumberCount stayed 1Use @select when the dependency list belongs to a getter and useNode(node) should update only for that getter's selected dependencies:
import { ReactiveNode, link, memo, select } from "@retreejs/core";
class TaskRow extends ReactiveNode {
@link public task!: { isCompleted: boolean };
@link public filter!: { isComplete: boolean | null };
@select()
get isVisible() {
return (
this.filter.isComplete === null ||
this.task.isCompleted === this.filter.isComplete
);
}
}@select() without a selector traps dependencies while the getter runs. Whole Retree-managed values read by the getter subscribe; property reads subscribe to the owner node but compare the specific property value; primitive values read by the getter compare. Pass an explicit selector when you want to choose or customize dependency slots:
import { ReactiveNode, memo, select } from "@retreejs/core";
class AttributeRow extends ReactiveNode {
public attributes: { id: string; label: string }[] = [];
public attributeId!: string;
@memo
private get _attribute() {
return this.attributes.find((check) => check.id === this.attributeId);
}
@select((self) => [
self.attributes,
self.attributeId,
self.dependency(self._attribute, [self._attribute?.id]),
])
get attribute() {
return this._attribute;
}
}ReactiveNode also exposes lifecycle hooks for setup, cleanup, and post-change synchronization:
onObserved()runs when the node gets its first activenodeChangedortreeChangedobserver.onUnobserved()runs when the node loses its last activenodeChangedortreeChangedobserver.onChanged()runs after the node receives a fresh reproxy because one of its own properties changed or one of its declared dependencies changed.
Use onObserved() and onUnobserved() for external resources that should only exist while something is observing the node. This keeps dependencies purely declarative instead of using it as a setup side effect.
import { ReactiveNode, ignore } from "@retreejs/core";
declare function subscribeToValue(
callback: (value: string) => void
): () => void;
class LiveValueNode extends ReactiveNode {
public value: string | null = null;
@ignore private unsubscribe: (() => void) | null = null;
get dependencies() {
return [];
}
protected onObserved(): void {
this.unsubscribe = subscribeToValue((value) => {
this.value = value;
});
}
protected onUnobserved(): void {
this.unsubscribe?.();
this.unsubscribe = null;
}
}Use onChanged() when you need to update derived state only after Retree has confirmed that the node actually changed. Retree runs onChanged() before listener callbacks flush. If no transaction is already active, Retree starts one so state updates made in onChanged() are bundled with the triggering change.
class SearchNode extends ReactiveNode {
public query = "";
public normalizedQuery = "";
get dependencies() {
return [];
}
protected onChanged(): void {
const next = this.query.trim().toLowerCase();
if (this.normalizedQuery === next) {
return;
}
this.normalizedQuery = next;
}
}ReactiveNode provides memo to cache the result of a getter, similar in spirit to React's useMemo. Use it to skip expensive recomputation when the values it depends on haven't changed.
The simplest path is to decorate computed getters and deterministic methods with @memo / @fnMemo. With no arguments, the decorator automatically traps the Retree reads inside the getter or method and invalidates the cache when those values change.
Pass a comparison function only when you want finer control over the cache keys, such as depending on a cheaper primitive instead of every value read by the computation. The method forms (this.memo(fn, deps?) and this.memo(key, fn, deps?)) are still available when a decorator does not fit the shape of the code.
The cache key is the getter's property name. Use @memo or @memo() with no comparisons for automatic dependency trapping.
import { Retree, ReactiveNode, memo } from "@retreejs/core";
interface Card {
text: string;
}
class ListFilter extends ReactiveNode {
public list: Card[] = [];
public searchText = "";
@memo
get filteredList(): Card[] {
return this.list.filter((c) => c.text === this.searchText);
}
get dependencies() {
return [this.dependency(this.list)];
}
}Pass a comparison function when the automatic trapper is broader than you want:
class ListFilter extends ReactiveNode {
public list: Card[] = [];
public searchText = "";
// Only invalidate when the list identity/reproxy or search text changes.
@memo((self: ListFilter) => [self.list, self.searchText])
get filteredList(): Card[] {
return this.list.filter((c) => c.text === this.searchText);
}
get dependencies() {
return [this.dependency(this.list)];
}
}Use this when a method is deterministic for a given argument list plus the Retree values it reads. Method arguments are always shallow-compared. Use @fnMemo or @fnMemo() with no comparisons for automatic dependency trapping.
import { Retree, ReactiveNode, fnMemo } from "@retreejs/core";
class ListFilter extends ReactiveNode {
public list: Card[] = [];
public searchText = "";
@fnMemo
public filteredList(limit: number): Card[] {
return this.list
.filter((c) => c.text === this.searchText)
.slice(0, limit);
}
get dependencies() {
return [this.dependency(this.list)];
}
}Pass a comparison function when you want to manually choose the cache keys. The function receives the current instance followed by the method arguments:
class ListFilter extends ReactiveNode {
public list: Card[] = [];
public searchText = "";
@fnMemo((self: ListFilter, limit: number) => [
self.list,
self.searchText,
limit,
])
public filteredList(limit: number): Card[] {
return this.list
.filter((c) => c.text === this.searchText)
.slice(0, limit);
}
get dependencies() {
return [this.dependency(this.list)];
}
}Use this when you want decorator-like cache behavior but need to wrap only part of a getter body. The cache key is derived from the active getter's name automatically. Throws if called outside a getter, or more than once in the same getter without an explicit key.
class ListFilter extends ReactiveNode {
public list: Card[] = [];
public searchText = "";
get filteredList(): Card[] {
return this.memo(() =>
this.list.filter((c) => c.text === this.searchText)
);
}
get dependencies() {
return [this.dependency(this.list)];
}
}Use this when you need multiple memo cells in the same getter, or when caching a result inside a method.
class ListFilter extends ReactiveNode {
public list: Card[] = [];
public searchText = "";
get pair(): { filtered: Card[]; count: number } {
const filtered = this.memo(
"filtered",
() => this.list.filter((c) => c.text === this.searchText),
[this.list, this.searchText]
);
const count = this.memo("count", () => filtered.length, [filtered]);
return { filtered, count };
}
get dependencies() {
return [this.dependency(this.list)];
}
}The same comparison rules apply to all forms. For @fnMemo, the method arguments are also compared every call:
| Form / comparisons | Behavior |
|---|---|
@memo, @memo(), @fnMemo, @fnMemo(), or omitted this.memo comparisons |
Automatically trap Retree reads and recompute when a trapped value changes. |
Function returns undefined |
Recompute whenever the ReactiveNode reproxies (a property was set on it or one of its dependencies changed). Useful as a "compute once per render." |
Function returns [] |
Compute once and cache forever for that instance. |
Function returns [a, b, ...] |
Recompute when any cell shallow-changes (compared with Object.is). |
Tree-node cells in deps are compared by their latest reproxy identity, not by the stable buildProxy reference. That's why [this.list, this.searchText] correctly invalidates when list mutates — without this, this.list would always look unchanged because Retree returns the same buildProxy for the lifetime of the tree.
The cache is per-instance and stored in a WeakMap keyed by the unproxied ReactiveNode, so it follows the instance's lifetime and is naturally garbage-collected when the node is dropped.
Retree keeps a pure ownership tree: a node can have one structural parent. If an existing node needs to appear somewhere else, choose the operation that matches your intent.
Retree.move(node, destination, key?)transfers ownership. Arrays accept a numeric insertion index or append when omitted. Maps and objects require a key. Sets ignore the key.Retree.link(node)creates a reactive pointer object with.current. The link can be stored in the tree without reparenting the target.@linkmarks aReactiveNodefield as a reactive pointer. Replacing the field emits on the owner, but the assigned node keeps its existing parent.Retree.clone(node)creates a detached copy that can become a new child elsewhere.
import { Retree, ReactiveNode, link } from "@retreejs/core";
const task = projectA.tasks[0];
Retree.move(task, projectB.tasks);
root.selectedTask = Retree.link(task);
root.selectedTask.current.title = "Selected";
class EditorState extends ReactiveNode {
@link public selectedTask: Task | null = null;
get dependencies() {
return [];
}
}@ignore is a class-field decorator that excludes a property of a ReactiveNode from Retree's reactivity system. Reads and writes to the field still work normally — what's skipped is listener emission:
- Nested mutations like
this.cache.foo = 1do not firenodeChanged/treeChangedon theReactiveNodeor its ancestors. - Replacing the field at the top level (
this.cache = {...}) likewise skips emission. - The proxy will not wrap the field's value or build child proxies underneath it.
Use it for state that lives on a ReactiveNode but shouldn't participate in the tree — caches, scratch buffers, framework handles, references to objects already managed elsewhere, etc.
import { Retree, ReactiveNode, ignore } from "@retreejs/core";
import { useNode } from "@retreejs/react";
class Counter extends ReactiveNode {
public count = 0;
// Mutations under `cache` do not trigger Retree listeners or re-renders.
@ignore public cache: Record<string, unknown> = {};
get dependencies() {
return [];
}
}
const node = Retree.root(new Counter());
const state = useNode(node);
// ❌ no re-render
node.cache.something = 1;
// ❌ no re-render — replacing the field also skips emission
node.cache = { other: 2 };
// ✅ re-renders
node.count += 1;Caveat: because the proxy doesn't wrap an @ignore-d field's value, plain objects stored under it lose Retree.parent(...) and won't appear in treeChanged notifications. If you store an existing Retree-managed node in an ignored field, Retree does not reparent it, but reads still return that node's latest reproxy.
If you are making multiple changes to one or many nodes at once, you can use Retree.runTransaction function to only set to React state once per instance of useNode or useTree. Here is an example:
const _counter = Retree.root({ count: 0 });
const counter = useNode(_counter);
// Will only emit "nodeChanged" once
Retree.runTransaction(() => {
counter.count = counter.count + 1;
counter.count = counter.count * 2;
});If you want to skip re-rendering on a change, you can use the Retree.runSilent function. Here is an example:
const counter = Retree.root({ count: 0, multiplier: 1 });
const counterState = useNode(counter);
// Skip re-render on setting the multiplier
function onClickIncrementMultiplier() {
Retree.runSilent(() => {
counterState.multiplier += 1;
});
}
// Re-render when user clicks button
function onClickIncrementCount() {
counterState.count = counterState.count * counterState.multiplier;
}Note: if you want nodes to still be reproxied when they change for React's comparison checks but don't yet want to re-render, set the skipReproxy prop in Retree.runSilent to false.
See the Cat Facts sample or recursive tree for more examples of @retreejs/react.
Install with npm:
npm i @retreejs/coreInstall with yarn:
yarn add @retreejs/coreRetree Core allows for easy observations of deeply nested values in any object. It is a general purpose package for JavaScript/TypeScript modules, though it is probably best paired with @retreejs/react.
import { Retree } from "@retreejs/core";
import { v4 as uuid } from "uuid";
class Todo {
readonly id = uuid();
public text = "";
public checked = false;
toggle() {
this.checked = !this.checked;
}
delete() {
// Get parent of the Todo, which is Array<Todo>
const parent = Retree.parent(this);
if (!Array.isArray(parent)) return;
const index = parent.findIndex((c) => this.id === c.id);
parent.splice(index, 1);
}
}
class TodoList {
public todos: Todo[] = [];
add() {
this.todos.push(new Todo());
}
}
const tree = Retree.root(new TodoList());
// Listen for changes to the todo list (e.g., todo created)
const unsubscribe = Retree.on(tree.todos, "treeChanged", (todos) => {
console.log("list updated", todos);
});
tree.add();
tree.todos[0].toggle();
tree.todos[0].delete();
unsubscribe();Use Retree.on when you need events outside React:
Retree.on(tree.todos, "nodeChanged", () => console.log("list changed"));
Retree.on(tree.todos, "treeChanged", () =>
console.log("list or child changed")
);
tree.todos.push(new Todo()); // ✅ nodeChanged, ✅ treeChanged
tree.todos[0].toggle(); // ❌ nodeChanged on list, ✅ treeChanged on listUse Retree.select when only one selected value or dependency list matters:
const unsubscribeDoneCount = Retree.select(
tree.todos,
(todos) => todos.filter((todo) => todo.checked).length,
(doneCount) => console.log(doneCount),
{ listenerType: "treeChanged" }
);
tree.todos[0].toggle(); // ✅ emits if done count changes
tree.todos[0].text = "Docs"; // ❌ no emit if done count is unchanged
unsubscribeDoneCount();Retree.select can also infer dependencies when you pass only a selector function and callback:
Retree.select(
() => tree.todos.filter((todo) => todo.checked).length,
(doneCount) => console.log(doneCount)
);Retree.select also accepts ordered dependency lists. Reactive entries subscribe; primitive entries compare:
Retree.select(
row,
(self) => [self.attributes, self.attributeId, self.attribute],
([, , attribute]) => console.log(attribute)
);Dependency-list subscriptions in Retree.select are observational: selected dependency changes can call the callback, but they do not force the node passed to Retree.select to receive a fresh reproxy.
Use Retree.move, Retree.link, and Retree.clone to make ownership explicit:
const task = projectA.tasks[0];
Retree.move(task, projectB.tasks); // ✅ transfers ownership
projectA.selected = Retree.link(task); // ✅ points at task without reparenting
projectA.tasks.push(Retree.clone(task)); // ✅ independent copyUse Retree.parent for tree-local operations:
const parent = Retree.parent(tree.todos[0]);
if (Array.isArray(parent)) {
parent.splice(0, 1); // ✅ removes the task and emits on the parent list
}Use ReactiveNode.prepareTree when you want lazy child proxy setup to happen before first render or first interaction:
class ProjectState extends ReactiveNode {
public tasks = [{ title: "Docs", comments: [] }];
get dependencies() {
return [];
}
}
const root = Retree.root(new ProjectState());
root.prepareTree({ depth: 1 });See the useNode React hook or example 01 project for more example usages.
Retree Convex lets a ReactiveNode own a Convex client, create typed query nodes with this.query(...), run one-off queries with this.queryOnce(...), call actions and mutations, subscribe to paginated queries, and track connection state. Query results are written into Retree state, Convex document arrays are reconciled by _id by default, and optimistic updates can be applied narrowly to existing query state.
Install with npm:
npm i @retreejs/core @retreejs/convex convexInstall with yarn:
yarn add @retreejs/core @retreejs/convex conveximport { ConvexNode, ConvexQueryNode } from "@retreejs/convex";
import { ConvexClient } from "convex/browser";
import { api } from "../convex/_generated/api";
import { Id } from "../convex/_generated/dataModel";
class TasksState extends ConvexNode {
public readonly tasks: ConvexQueryNode<typeof api.tasks.get>;
constructor(convexUrl: string) {
const client = new ConvexClient(convexUrl);
super(client);
this.tasks = this.query(api.tasks.get);
}
get dependencies() {
return [];
}
public toggleCompleted(taskId: Id<"tasks">): Promise<null> {
const toggleCompleted = this.mutation(api.tasks.toggleCompleted);
return toggleCompleted(
{ taskId },
{
withOptimisticUpdate: (ctx) => {
this.tasks.optimisticUpdate({
ctx,
apply(tasks) {
const task = tasks.find((candidateTask) => {
return candidateTask._id === taskId;
});
if (!task) return;
task.isCompleted = !task.isCompleted;
},
});
},
}
);
}
}Docs are hosted at https://ryanbliss.github.io/retree/.
Copyright (c) Ryan Bliss. All rights reserved. Licensed under MIT license.
Credit to Fluid Framework's new SharedTree feature, which has served as a major inspiration for this project. If you want to use collaborative objects, I recommend checking out Fluid Framework!