retree
DocsAPIWhy Retree

Start here

  • Quickstart
  • Thinking in Retree
  • Common pitfalls

React

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

Core

  • Events & subscriptions
  • Tree operations
  • Transactions & silent writes

View models

  • ReactiveNode & decorators
  • Setup & decorators

Going deeper

  • Performance
  • Convex integration

Start here

  • Quickstart
  • Thinking in Retree
  • Common pitfalls

React

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

Core

  • Events & subscriptions
  • Tree operations
  • Transactions & silent writes

View models

  • ReactiveNode & decorators
  • Setup & decorators

Going deeper

  • Performance
  • Convex integration
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/react
  • @retreejs/convex
  • @retreejs/react-convex

Project

  • Why Retree
  • GitHub
  • npm
  • llms.txt

Docs

Edit on GitHub

useRaw

Subscribe like useNode, read raw — native-speed, proxy-free reads for render bodies that read wide, with toManaged to get the managed node back for child props, writes, and method calls deep in the tree.

useRaw subscribes exactly like useNode (nodeChanged by default) but returns the node's data raw — the live, proxy-free object behind it. Use it when the render body itself reads wide: big tables, canvas layers, subtree serialization.

When to use

Use useRaw for components that read wide during render and want native-speed, proxy-free reads. Siblings: useNode for focused components that own one node, useTree for small subtrees that re-render together, useSelect for narrow derived values, useRoot for component-lifetime roots — or compare them all in Choosing a hook.

Signature#

function useRaw<TNode extends TreeNode>(
    node: TNode | NodeFactory<TNode>,
    options?: UseRawOptions
): [TNode, ToManaged];

interface UseRawOptions {
    /** Defaults to "nodeChanged", exactly like useNode. */
    listenerType?: "nodeChanged" | "treeChanged";
}

/** Resolves a raw value back to its Retree-managed node. */
type ToManaged = <T extends TreeNode>(rawValue: T) => T | undefined;

The [raw, toManaged] tuple#

  • raw is the live raw object behind the node (Retree.raw) — zero-copy and guaranteed proxy-free. Every property read skips the proxy entirely. Because it is live, any render — including renders triggered by a parent — reads current state.
  • toManaged resolves a raw value back to its managed node. Direct children of the subscribed node always resolve (object and array children, Map values, and Set members are materialized on demand). Use it to pass nodes to child components while mapping over raw content — or to write and call methods through the managed node after a raw scan finds the value you want.

Invalidation follows the same contract as useNode: the component re-renders when the node's own data changes. Deep changes re-render only when declared — via the node's dependencies / @select, via useSelect for derived views, or via the listenerType: "treeChanged" opt-in.

The wide-read pattern, three levels deep#

The demo below is a board whose cards are class instances with methods, living three levels down: board → columns → cards. The column view maps over raw cards — proxy-free reads — while each row still receives a managed node via toManaged, keeping its own subscription and write surface. The "Complete next" button is the pattern to steal: a native-speed scan over raw finds the card, then toManaged(rawCard) resolves the managed node so the code can call card.complete() — a method on an object deep in the tree, mutating through Retree like any other write:

/App.tsx
import { Retree } from "@retreejs/core";
import { useNode, useRaw } from "@retreejs/react";
import React from "react";

let nextId = 4;

class Card {
    constructor(
        public id: number,
        public title: string,
        public done = false
    ) {}
    complete() {
        this.done = true;
    }
}

const board = Retree.root({
    name: "Launch",
    columns: [
        {
            name: "Doing",
            cards: [
                new Card(1, "Write the docs", true),
                new Card(2, "Ship the site"),
                new Card(3, "Record the demo"),
            ],
        },
    ],
});

type Column = (typeof board.columns)[number];

// Inline render counter.
function useRenders() {
    const count = React.useRef(0);
    count.current += 1;
    return count.current;
}

function ColumnView({ column }: { column: Column }) {
    // Subscribe to the deep cards array; read it raw. Re-renders only when
    // the array itself changes: add / remove / reorder.
    const [rawCards, toManaged] = useRaw(column.cards);
    const renders = useRenders();

    function completeNext() {
        // Native-speed scan over raw...
        const rawCard = rawCards.find((card) => !card.done);
        if (!rawCard) return;
        // ...then resolve the managed node and call its method. The write
        // emits like any other — only that card's row re-renders.
        toManaged(rawCard)?.complete();
    }

    return (
        <div>
            <p>
                {column.name} column renders: {renders}
            </p>
            <button onClick={completeNext}>
                Complete next (raw scan → toManaged)
            </button>
            <ul>
                {rawCards.map((rawCard) => (
                    <CardRow key={rawCard.id} card={toManaged(rawCard)!} />
                ))}
            </ul>
        </div>
    );
}

const CardRow = React.memo(function CardRow({ card }: { card: Card }) {
    // Node prop: own subscription, write surface.
    const state = useNode(card);
    const renders = useRenders();
    return (
        <li>
            <label>
                <input
                    type="checkbox"
                    checked={state.done}
                    onChange={() => (state.done = !state.done)}
                />
                {state.title}
            </label>{" "}
            row renders: {renders}
        </li>
    );
});

export default function App() {
    const column = board.columns[0];
    return (
        <div>
            <button
                onClick={() =>
                    column.cards.push(new Card(nextId, `Card ${nextId++}`))
                }
            >
                Add card
            </button>{" "}
            <button onClick={() => column.cards.pop()}>Remove last</button>
            <ColumnView column={column} />
        </div>
    );
}

Toggle a row (or click "Complete next") and only that row re-renders; add or remove a card and only the column re-renders. Depth costs nothing here: the subscription is on column.cards no matter how far from the root it lives.

Functions deep in the tree#

The toManaged(rawCard)?.complete() line above is the important one. Raw values are plain data — calling a method through them would bypass Retree entirely — but toManaged hands back the managed node, so class methods keep working at any depth in the tree: the write emits, the node reproxies, and exactly the right components re-render.

One constraint to respect, stated plainly: toManaged guarantees resolution only for direct children of the node you subscribed to (object and array children, Map values, and Set members are materialized on demand). That's why the demo subscribes to column.cards and resolves cards — one level apart. Deeper raw values resolve only once they've been materialized by a managed read.

Outside a component — or when the value you need is deeper than one level — core has the same bridge:

// Scan raw at native speed, get the managed node back in one call.
const card = Retree.peekInto(board.columns[0].cards, (rawCards) =>
    rawCards.find((candidate) => candidate.title === "Ship the site")
);
card?.complete(); // ✅ a managed-node method call — emits like any write

Retree.peekInto(node, fn) runs fn against the raw object and resolves the returned value to its managed node when one exists; Retree.managed(rawValue) is the lower-level inverse of Retree.raw. The same materialization rule applies: values resolve once they've been read through the managed tree (the rendered cards here already have been) — for cold subtrees, traverse the path once or use prepareTree. Both APIs are covered in Performance — read wide with raw.

The rules of raw#

  • Never write to raw values. Writes go through nodes — raw is a read-only view, and writing to it bypasses events, reproxying, and every subscriber.
  • Pass nodes to children via toManaged, never raw values. Nodes carry subscriptions, writes, navigation, and identity; raw is a local read view.
  • Never use raw references as React.memo props, useMemo deps, or equality tokens. Raw references keep the same identity across changes, so comparisons against them never see a change. Nodes remain the identity currency.
  • Don't filter or aggregate raw content inline when membership must stay in lockstep with deep fields — that is derived state; use useSelect.

Raw purity is a guarantee: raw subtrees stay proxy-free under every write path, so something like structuredClone(raw) is a valid point-in-time copy. The managed-vs-raw distinction is covered in Thinking in Retree.

Pitfalls#

  • Everything in the rules list above — each one produces silent staleness rather than an error, which makes them the sharpest-edged mistakes in Common pitfalls.
  • toManaged returns undefined for values that are not (or not yet) resolvable to a managed node. Direct children of the subscribed node are guaranteed to resolve; deeper raw values resolve only once materialized.

Reference#

Full signature and remarks: useRaw API reference, UseRawOptions, and ToManaged.

← PrevioususeSelectNext →Events & subscriptions

On this page

  • Signature
  • The [raw, toManaged] tuple
  • The wide-read pattern, three levels deep
  • Functions deep in the tree
  • The rules of raw
  • Pitfalls
  • Reference