Retree - v1.0.0
    Preparing search index...

    Retree - v1.0.0

    Retree packages

    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 docs
    

    The generated static site is written to docs/ and ignored by Git. GitHub Pages can host it from the included docs workflow after the repository's Pages source is set to GitHub Actions.

    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/react
    

    Install with yarn:

    yarn add @retreejs/core @retreejs/react
    

    It's extremely easy to get started with Retree. There are two React hooks: useNode and useTree. Each have specific advantages while leveraging the same simple interface.

    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:

    1. Pass some object into Retree.root, e.g., const root = Retree.root({ foo: "bar", list: [] })
    2. Make the response stateful using useNode, e.g., const rootState = useNode(root)
    3. Render values from the object in your component, e.g., <h1>{fooState.foo}</h1>
    4. Set values like you normally would in JS/TS, e.g., fooState.foo = "moo"
    5. Ensure child nodes are passed to useNode when 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 acceps 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.

    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 interchangably.
    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 comparisons

    While 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 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.

    import { Retree, ReactiveNode } from "@retree/core";
    import { useNode } from "@retree/core";
    // Declare a class that extends `ReactiveNode`
    class Node extends ReactiveNode {
    numbers: number[] = [];
    constructor() {
    super();
    }
    // Get count of even numbers in the list
    get evenNumberCount(): number {
    return this.numbers.filter((number) => number % 2 === 0).length;
    }
    // Implement abstract `dependencies` getter with list of dependencies
    get dependencies() {
    return [
    // Similar to React, dependencies cannot change in length or order between updates.
    this.dependency(
    // The dependency node to listen to changes for.
    this.numbers,
    // Optional comparison dependencies so that only specific changes cause `Node` instances to updates.
    // If not provided, all changes to `this.numbers` would cause `Node` instances to update.
    [this.evenNumberCount]
    ),
    ];
    }
    }
    // Create root `ReactiveNode` instance and listen for changes in `useNode`
    const node = Retree.root(new Node());
    const nodeState = useNode(node);

    // ✅ Will re-render
    node.list.push(2);
    node.list.push(100);
    // ❌ Will not re-render
    node.list.push(3);
    node.list.push(99);

    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.

    There are three ways to memoize: a keyless this.memo(fn, deps?) form (cleanest, when you want one cache per getter), an explicit-key this.memo(key, fn, deps?) form (for stacking multiple memos in one getter or memoizing inside a method), and a @memo decorator form.

    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.

    import { Retree, ReactiveNode } from "@retreejs/core";

    interface Card {
    text: string;
    }

    class ListFilter extends ReactiveNode {
    public list: Card[] = [];
    public searchText = "";

    get filteredList(): Card[] {
    return this.memo(
    () => this.list.filter((c) => c.text === this.searchText),
    [this.list, this.searchText]
    );
    }

    get dependencies() {
    return [this.dependency(this.list)];
    }
    }

    The cache key is the getter's property name. Pass a function that returns the comparisons array — it's invoked with the current instance on every read, so the values are always live.

    import { Retree, ReactiveNode } from "@retreejs/core";
    import { memo } from "@retreejs/core";

    class ListFilter extends ReactiveNode {
    public list: Card[] = [];
    public searchText = "";

    @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 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 deps rules apply to all three forms:

    deps argument Behavior
    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."
    [] Compute once and cache forever for that instance.
    [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.

    @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 = 1 do not fire nodeChanged / treeChanged on the ReactiveNode or 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, you also lose Retree.parent(...) for objects stored under it, and they won't appear in treeChanged notifications. Treat ignored fields as opaque from Retree's perspective.

    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 "valueChanged" 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 onClickIncrementMultipler() {
    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/core
    

    Install with yarn:

    yarn add @retreejs/core
    

    Retree 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.todos.add();
    tree.todos[0].toggle();
    tree.todos[0].delete();
    unsubscribe();

    See the useNode React hook or example 01 project for more example usages.

    Licensing & Copyright

    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!