Docs
useTree
Subscribe a component to a node and every descendant. Broad subtree invalidation — powerful for small subtrees, expensive as a default.
useTree subscribes to treeChanged, so the component re-renders when the
node or any node below it changes. It trades granularity for convenience:
one hook call instead of a useNode per child.
Signature#
function useTree<T extends TreeNode = TreeNode>(node: T | NodeFactory<T>): T;Same shape as useNode — the only difference is the listener type:
treeChanged instead of nodeChanged.
Subtree invalidation#
Where useNode(node) re-renders for the node's own fields, useTree(node)
re-renders for everything under it: a field on the node, a field three
levels deep, an item pushed into a grandchild array. That is exactly right
for a component that aggregates over a subtree — and exactly wrong as a
default for a large app root, where every keystroke anywhere would re-render
it.
The demo below is the README's table example, reduced: each Row uses
useNode(row) and only re-renders for its own +1 clicks, while TotalRow
uses useTree(rows) and re-renders for every click in every row:
import { Retree } from "@retreejs/core";
import { useNode, useTree } from "@retreejs/react";
import React from "react";
const table = Retree.root({
rows: [
{ label: "row 1", count: 0 },
{ label: "row 2", count: 0 },
],
});
type Row = (typeof table.rows)[number];
// Inline render counter.
function useRenders() {
const count = React.useRef(0);
count.current += 1;
return count.current;
}
const RowView = React.memo(function RowView({ row }: { row: Row }) {
// Subscribed to this row only.
const state = useNode(row);
const renders = useRenders();
return (
<tr>
<td>{state.label}</td>
<td>{state.count}</td>
<td>
<button onClick={() => (state.count += 1)}>+1</button>
</td>
<td>renders: {renders}</td>
</tr>
);
});
function TotalRow({ rows }: { rows: Row[] }) {
// A sum over all rows: we want re-renders for every child change.
const rowsState = useTree(rows);
const renders = useRenders();
const sum = rowsState.reduce((total, row) => total + row.count, 0);
return (
<tr>
<td>total</td>
<td>{sum}</td>
<td />
<td>renders: {renders}</td>
</tr>
);
}
export default function App() {
// The table shell subscribes narrowly: only add/remove re-renders it.
const rows = useNode(table.rows);
const renders = useRenders();
return (
<div>
<p>App renders: {renders}</p>
<table>
<tbody>
{rows.map((row, index) => (
<RowView key={index} row={row} />
))}
<TotalRow rows={rows} />
</tbody>
</table>
<button
onClick={() =>
table.rows.push({
label: `row ${table.rows.length + 1}`,
count: 0,
})
}
>
Add row
</button>
</div>
);
}What changes identity on a deep write#
A treeChanged re-render is only half the story — useTree also interacts
with comparisons (React.memo, hook dependency arrays) through reproxying.
On a deep write, the changed node and its ancestors get fresh identities;
everything off that path stays referentially stable:
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 comparisonsReproxy identity is defined properly in
Thinking in Retree — it is what makes
React.memo and useMemo dependencies behave correctly with Retree nodes.
Pitfalls#
- Reaching for
useTreeat the root because nested writes weren't re-rendering auseNodecomponent. That fixes the symptom by re-rendering everything; the granular fix is auseNode(child)per rendered child. - Deriving one value from a subtree with
useTreere-renders for every descendant change, even ones that don't affect the value —useSelectwithlistenerType: "treeChanged"only re-renders when the selection changes.
More in Common pitfalls.
Reference#
Full signature and remarks: useTree API reference.