Recipe

Drill from an org chart into a people grid

The tree hands you an id, not a subtree. With lazy loading it does not know who reports to that manager, which decides what the table below is allowed to show.

An org chart with lazily loaded reports, driving a people gridOpen in new tab

Built with ApexTree, ApexGrid

Click a box on the org chart, see that team in the table below. The wiring looks like the chart-to-grid drill-down, and for a small company it is. For a real directory it is not, because of one thing: with lazy loading the tree does not know who reports to that manager. It has an id and nothing under it.

So the interesting question is not how to pass a selection. It is what the grid is allowed to show when the client does not hold the answer.

npm install apextree apex-grid

What does the tree give you when a node is clicked?

Ids. Not nodes, not people, not a subtree:

import ApexTree from 'apextree'

const tree = new ApexTree(document.getElementById('tree'), {
  width: 900,
  height: 520,
  nodeWidth: 150,
  nodeHeight: 64,
  direction: 'top',
  contentKey: 'data',
  enableSelection: 'single', // off by default
})

const graph = tree.render(ORG_ROOT)

graph.onSelectionChange((ids) => {
  ids // ['eng'] - an array of strings, and that is all
})

Note where the API lives. enableSelection is an option on the constructor, but getSelection, setSelection and onSelectionChange are on the object render() returns, not on the ApexTree instance. The same is true of expand() and of exportToSvg(). Holding onto only the constructor result is the first thing that makes this recipe look impossible.

Selection is also disabled by default. Without enableSelection, nodes still take focus and show hover states, so a click looks like it worked and no selection is ever recorded.

Lazy children, and what they cost you

A directory with eight thousand people should not arrive as one payload. Mark a node as having children without supplying them, and ApexTree fetches on first expand:

const tree = new ApexTree(el, {
  // ...
  loadChildren: async ({ id, data }) => {
    const res = await fetch(`/api/org/${id}/reports`)
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    return (await res.json()).map((p) => ({
      id: p.employeeId,
      data: { name: p.name, title: p.title },
      hasChildren: p.directReportCount > 0,
    }))
  },
})

graph = tree.render({
  id: 'ceo',
  data: { name: 'Ada Okafor', title: 'CEO' },
  children: [
    { id: 'eng', data: { name: 'Engineering' }, hasChildren: true },
    { id: 'sales', data: { name: 'Sales' }, hasChildren: true },
  ],
})

hasChildren: true with no children is what arms the loader. Expanding shows a spinner and calls loadChildren; returning an empty array tells the tree the node turned out to be a leaf and drops its expand affordance.

Three behaviours are worth knowing before you write the loader, all measured on apextree 2.1.1.

The context does not carry the name. LazyChildrenContext is typed as { data, id, name }, and its name is declared a required string documented as the display name of the node being expanded. It is undefined on every call. The key is present, the value is not, so a loader that does anything with ctx.name gets undefined rather than an error. Read the label from ctx.data, which does carry it, or ask the graph:

loadChildren: async ({ id, data }) => {
  const label = data?.name ?? graph.getNodeLabel(id)
  // ...
}

A rejection is retryable, and uncached. Throwing leaves the node collapsed so the reader can click again, which is the behaviour you want. It also means every retry calls your endpoint again: expanding a failing node twice produced two loader calls. If your API is rate-limited, put the backoff in the loader.

Nothing is preloaded. After expanding one branch, the tree holds exactly the nodes it has been shown. Everything else is a hasChildren flag.

The decision: what does the grid show?

This is the section the recipe exists for, and the answer follows from the previous paragraph rather than from taste.

Grid showsNeedsRight when
The selected person onlynothing beyond the idThe tree is the navigation and the grid is a detail panel
Loaded descendantsa walk of what is in memoryThe whole org is loaded up front
All descendants, re-queriedone server call per selectionAnything lazily loaded

With lazy loading, the middle row is the trap. It is the one that looks correct in development, where you expanded the branch you were testing, and understates in production, where the reader clicks a collapsed manager and sees the four people who happen to be in memory rather than the four hundred who report to them. A count that is silently wrong is worse than a spinner.

So: if the tree is lazy, the grid asks the server.

graph.onSelectionChange(async (ids) => {
  const id = ids[0]
  if (!id) {
    grid.data = []
    return
  }
  // The tree cannot answer "who reports to this person, transitively".
  // It never had that data. Ask the thing that does.
  grid.data = await fetchReports(id, { recursive: true })
})

If the whole directory is loaded up front, the walk is the cheaper answer and getNodeMap() gives you the structure to do it:

function descendantIds(graph, rootId) {
  const nodes = graph.getNodeMap() // plain object, keyed by id
  const out = []
  const stack = [rootId]
  while (stack.length) {
    const id = stack.pop()
    const node = nodes[id]
    if (!node) continue
    out.push(id)
    // Both arrays hold ids, not nodes. Collapsing moves a node's children
    // out of `children` and into `hiddenChildren`, so walking only the
    // first one stops at the first collapsed branch.
    stack.push(...(node.children ?? []), ...(node.hiddenChildren ?? []))
  }
  return out
}

Three things about that map are worth stating, because each one is a wrong guess away from a bug. getNodeMap() returns a plain object rather than a Map, so membership is id in nodes, not nodes.has(id). children and hiddenChildren hold id strings, not node objects, so child.id is undefined. And the keys on an entry are conditional: hiddenChildren only appears once a node has been collapsed, and hasChildren only on a node that declared it, which is why both are read with a default above.

The map itself stays complete across collapse: collapsing a branch moves ids between the two arrays and removes nothing, so a collapsed subtree is still walkable. It is the lazy case, where the nodes were never fetched, that the walk cannot see.

What breaks first

A selection that points at nobody. setSelection() does not validate. Pass an id that exists nowhere in the tree and it is accepted, and getSelection() hands it straight back:

graph.setSelection(['this-id-does-not-exist-anywhere'])
graph.getSelection() // ['this-id-does-not-exist-anywhere']

Nothing warns. This matters because the obvious feature built on top of this recipe, restoring a selection from a URL or from saved state, is exactly the case where the id comes from outside and may be stale, deleted, or from another tenant. The selection then reports a person who is not there and the grid fetches against their id.

getNodeMap() is the validation the selection does not do:

function selectIfPresent(graph, id) {
  const nodes = graph.getNodeMap()
  if (!(id in nodes)) return false // deleted, or not loaded yet
  graph.setSelection([id])
  return true
}

Note the two meanings of false there, and that you cannot tell them apart from the client. With lazy loading, "not in the map" covers both "this person left the company" and "this person is three levels down a branch nobody has expanded". If restoring a deep link matters, have the server return the path from the root and expand along it before selecting, rather than treating absence as an error.

The selection echo. setSelection() re-emits onSelectionChange, so programmatic selection is indistinguishable from a click. Restore a selection inside a handler that reacts to selection and the loop starts inside the function meant to end it. The guard is the same flag the map recipe uses, and the reason is the same, so if you are cross-filtering more than two components put the selection in a shared store rather than wiring the tree to the grid directly.

A stale grid under a slow fetch. Selections arrive faster than requests resolve, so two quick clicks can land the first response after the second and leave the grid showing the wrong team under the right highlight. Sequence them:

let latest = 0

graph.onSelectionChange(async (ids) => {
  const token = ++latest
  const id = ids[0]
  if (!id) return void (grid.data = [])

  const rows = await fetchReports(id, { recursive: true })
  if (token !== latest) return // a newer selection already won
  grid.data = rows
})

Row identity, if any of this is persisted. The grid captures selected rows by positional index unless you configure grid.rowId, and an index does not survive a reload that reorders. For a people grid fed by a changing org, set it:

grid.rowId = (row) => row.employeeId

When to use something else

If the reader's question is "who reports to whom", the tree is the answer on its own and the grid is ceremony. Reach for this pairing when the question has two halves: a structural one the tree answers, and a tabular one it cannot, like tenure, cost centre, or open requisitions per team.

If there is no hierarchy in the question at all, and the reader wants to filter people by department, use the grid's own tree data mode instead. One component, one mental model, and no cross-component contract to keep correct.

And if the hierarchy is large but the reader always arrives looking for one person, a search field over the directory beats expanding four levels of boxes. ApexTree ships search and breadcrumbs for exactly that path.

Which plan covers this?

ApexTree is included from the Pro plan upward, so it is not covered by the Community tier. ApexGrid is included on every plan, including Community, which is free for organizations under $2M USD in annual revenue.

ApexTree renders in full without a licence key, watermarked, so the lazy-loading and selection behaviour described here can be tried before buying anything.

Flat parentId to nested children, in both directions, and collapse state across a re-render

See the pieces running

Reference documentation

Frequently Asked Questions

How do I detect which node was clicked in ApexTree?

Set enableSelection to single or multi on the constructor, then subscribe with graph.onSelectionChange, where graph is the object render() returns. The payload is an array of id strings, not nodes, so resolving a person is your job. Selection is disabled by default, and without it nodes still take focus and show hover states, so a click looks like it worked while nothing is recorded.

How do I lazy-load children in an org chart?

Mark a node hasChildren: true with no children array and supply a loadChildren function. Expanding shows a spinner and calls it with the node id; return the child nodes and ApexTree splices them in. Return an empty array for a node that turned out to be a leaf and its expand affordance is dropped. Throwing leaves the node collapsed so the reader can retry, and each retry calls your endpoint again, so put any backoff inside the loader.

Should the grid show the selected person or their whole team?

If the tree loads lazily, re-query the server for the full subtree. Walking what is in memory is the trap: it looks right in development, where you expanded the branch you were testing, and understates in production, where a reader clicks a collapsed manager and sees the handful of people who happen to be loaded rather than everyone who reports to them. A silently wrong count is worse than a spinner.

Does ApexTree validate the ids passed to setSelection?

No. Measured on apextree 2.1.1, setSelection accepts an id that exists nowhere in the tree and getSelection hands it straight back, with no warning. That matters when the id comes from outside, such as a restored deep link, because the selection then reports a person who is not there. Check the id against graph.getNodeMap() first, which is a plain object keyed by id, so the test is "id in nodes" rather than a Map lookup.

Why is the display name missing in my loadChildren callback?

Because LazyChildrenContext.name is undefined at runtime on apextree 2.1.1, although it is typed as a required string and documented as the display name of the node being expanded. The key exists and its value does not, so code that touches ctx.name gets undefined rather than an error. Read the label from ctx.data, which does carry it, or call graph.getNodeLabel(id).

Related

See lazy children running

The lazy-children demo fetches a branch on first expand, with the spinner, the empty-result case and the retry path.

Get started