Async Loading
Mark a node as having children without providing them up front — TreeKit fetches on first expand.
Set hasChildren: true on a node instead of a children array, and pass onLoadChildren. TreeKit calls it the first time the node is expanded, shows a small spinner in the chevron slot while the promise is pending, and merges the result in without ever touching your original data:
Show code
const data = [{ id: "remote-root", label: "Remote Drive", hasChildren: true }];
async function fetchChildren(node) {
const res = await fetch(`/api/files/${node.id}`);
return res.json(); // TreeNode[]
}
<TreeKit data={data} onLoadChildren={fetchChildren} />Expand "Remote Drive," then "Documents" or "Taxes" — each level loads independently, on demand.
| Prop | Type | Default | Description |
|---|---|---|---|
| onLoadChildren | (node: TreeNode) => Promise<TreeNode[]> | — | Called once per node, the first time it's expanded while it has hasChildren and no children array yet. |
Error handling & retry
If the promise rejects, TreeKit keeps the node expanded and shows "Failed to load" with a retry button in the default renderer, instead of silently failing or throwing. renderNode consumers get the same information via context.loadError and context.retryLoad():
renderNode={(ctx) => (
<div>
{ctx.node.label}
{ctx.isLoading && <Spinner />}
{ctx.loadError && (
<button onClick={ctx.retryLoad}>Retry</button>
)}
</div>
)}Loaded children merge structurally
data on read, reusing node object references for every branch untouched by a load. This means loading children for one node doesn't invalidate memoization anywhere else in the tree.A node is only re-fetched if it's collapsed and re-expanded and the previous load hasn't already populated it — once loaded, a node's children persist for the lifetime of the component (or until you change data).