Lazy Children

A hierarchy of ten thousand nodes should not be fetched to render the first three levels. Mark a node as having children without supplying them, and ApexTree fetches them the first time someone expands it.

The two pieces

Set hasChildren: true on a node with no loaded children, and supply a loadChildren function:

const data = {
  id: 'root',
  name: 'Acme Corp',
  children: [
    { id: 'eng',   name: 'Engineering', hasChildren: true },
    { id: 'sales', name: 'Sales',       hasChildren: true },
  ],
}

const tree = new ApexTree(document.getElementById('chart'), {
  loadChildren: async ({ id }) => {
    const res = await fetch(`/api/nodes/${id}/children`)
    return res.json()
  },
})
tree.render(data)

Such a node shows a normal expand affordance. Activating it swaps the button for an SVG spinner with aria-busy while the promise settles, then splices the returned subtree in through the reconciler, so the new nodes grow out of their parent like any other expand.

The context argument carries the node's id, its display name, and the raw data value at contentKey.

Return values

ReturnedResult
An array of NestedNodesSpliced in and reflowed
[]The node turned out to be a leaf; its expand affordance is dropped
A rejected promiseThe node stays collapsed so the user can retry

Returning an empty array is the correct answer for "I checked, and there are none" — it is not treated as a failure, and the node stops advertising children.

Error handling

A rejection leaves the node collapsed and retryable, so the tree never ends up stuck mid-load. Handle reporting inside your own function:

loadChildren: async ({ id, name }) => {
  try {
    const res = await fetch(`/api/nodes/${id}/children`)
    if (!res.ok) throw new Error(`${res.status} loading ${name}`)
    return res.json()
  } catch (err) {
    showToast(`Could not load ${name}`)
    throw err     // rethrow so ApexTree leaves the node collapsed
  }
}

Swallowing the error and returning [] would instead mark the node as a leaf, which is not what a failed request means.

Caching

loadChildren runs on first expand only. Once children are spliced in they are part of the tree, so collapsing and re-expanding does not refetch. If you need to invalidate them, push a fresh dataset through updateData.

Deduplicate in your own function if the same subtree can be requested from more than one place:

const inflight = new Map()

const tree = new ApexTree(el, {
  loadChildren: ({ id }) => {
    if (!inflight.has(id)) {
      inflight.set(id, fetch(`/api/nodes/${id}/children`).then((r) => r.json()))
    }
    return inflight.get(id)
  },
})

Deep loading

Batch verbs walk the tree as it currently exists, so expandAll() does not trigger a cascade of fetches for nodes that have not been loaded. To open a known deep path, load and expand along it:

async function revealPath(graph, ids) {
  for (const id of ids) {
    graph.expand(id)                  // triggers loadChildren when needed
    await waitForNode(graph, ids)     // your own poll on graph.getNodeMap()
  }
  graph.centerOnNode(ids.at(-1))
}

Accessibility

The spinner state sets aria-busy on the expand control, and its accessible label comes from locale.messages.loadingNodeLabel (default 'Loading…'). See Localization and RTL.