React table tree data — hierarchical rows
Some data is a hierarchy before anyone asks a question of it: a folder holds
files, a task has subtasks, an account rolls up to a parent. Pass getChildren
(nested data) or getParentId (a flat list with a parent column) and the table
renders that hierarchy — one chevron per parent, one indent step per level, in
all nine adapters.
<DataTable data={FILES} columns={columns} rowKey={(row) => row.id} getChildren={(row) => row.children}/>Without either prop the table is the flat list it always was. Nothing is a mode to switch on: a tree is declared by the data.
A tree is not a grouping
Section titled “A tree is not a grouping”They look alike and behave nothing alike, so AdaptTable ships them as two models rather than one with a flag:
groupBy |
getChildren / getParentId |
|
|---|---|---|
| Where the shape comes from | a value the reader picked | the rows themselves |
| Re-sorting a column | regroups | leaves the hierarchy alone |
| Depth | the grouping keys | whatever the data has |
| A parent row | a synthetic header | a real row with real cells |
Sharing one implementation would mean either grouping that cannot express a real hierarchy, or a tree that reshuffles when someone sorts a column. A table may use both — group a tree by a column and the groups hold trees.
Which column carries the chevron
Section titled “Which column carries the chevron”The first column, unless treeColumn names another:
<DataTable {...props} getChildren={getChildren} treeColumn="name" />The chevron and the indent go in that column’s cell, wrapping its content — so the name steps in with its depth, not just the chevron. A leaf gets a spacer the same width as a chevron, so a folder’s children line up under its name rather than under its disclosure control.
Holding the open set
Section titled “Holding the open set”Uncontrolled by default — the table remembers which nodes are open. Pass
expandedIds to own it, and onExpandedIdsChange to hear about clicks:
const [expandedIds, setExpandedIds] = useState<string[]>(["src"]);
<DataTable {...props} getChildren={getChildren} expandedIds={expandedIds} onExpandedIdsChange={setExpandedIds}/>;A tree starts folded, so the state is the set that is open — an empty array is a closed tree, not an expanded one.
A flat list with a parent column
Section titled “A flat list with a parent column”The commonest shape out of a database is one table with a parentId. Read it
with getParentId and the table assembles the hierarchy itself:
<DataTable data={rows} // [{ id: "1" }, { id: "2", parentId: "1" }] columns={columns} rowKey={(row) => row.id} getParentId={(row) => row.parentId}/>Rows whose parent is not in the data are roots — so a filtered page still renders rather than vanishing into a parent that never arrived.
Children fetched when a branch is opened
Section titled “Children fetched when a branch is opened”A hierarchy of any size cannot arrive whole. Say which rows have children the browser has not fetched, and what to do when one is opened:
<DataTable data={rows} columns={columns} rowKey={(row) => row.id} getChildren={(row) => row.children} hasChildren={(row) => row.childCount > 0} onLoadChildren={async (row) => { const children = await fetchChildren(row.id); setRows((current) => withChildren(current, row.id, children)); }}/>hasChildren is what draws a chevron on a node with nothing under it yet.
onLoadChildren fires once per node — the second click while a request is out
does not ask again — and resolves when the children are in the data the table
reads; the table re-walks the hierarchy itself and needs nothing back.
The node opens immediately and shows it is working: its chevron carries
data-loading and aria-busy until the rows land, so nobody is left clicking a
control that appears to do nothing. A rejection clears the flag and leaves the
node closed and clickable, so the retry is the same gesture as the first attempt.
Headless: useLazyChildren holds the state (LazyChildrenState, options
UseLazyChildrenOptions) and the table’s tree bundle exposes loadingIds and
failedIds.
The whole tree on the server
Section titled “The whole tree on the server”A browser holding one page cannot know what is under a branch it has never seen, so a large hierarchy belongs to the side that has the data. Declare the capability and hand the source the open set:
const [expandedIds, setExpandedIds] = useState<string[]>([]);
const source = useQuerySource<Node, Params, Page>({ queryKey: ["files", expandedIds], queryFn: fetchFiles, supports: { tree: true }, expandedIds,});
<DataTable source={source} columns={columns} rowKey={(row) => row.id} getParentId={(row) => row.parentId} hasChildren={(row) => row.childCount > 0} expandedIds={expandedIds} onExpandedIdsChange={setExpandedIds}/>;The query carries expandedIds — the ids the reader has open — and the response
returns the rows of the tree that are visible: the roots, plus the children of
every open node, each with its parentId. The table assembles the hierarchy from
what arrived, so no row model changes between tiers.
Without supports: { tree: true } the field is never sent, and development says
so once rather than letting a server quietly ignore it. The same is true of
useServerData, which takes expandedIds in the same place.
Mobile
Section titled “Mobile”Cards keep the hierarchy. Each card steps in by its depth (logical margin, so it steps from the right in Arabic and Hebrew) and carries the same chevron, which folds the same nodes. A phone gets a tree, not a flattened list.
A real table under a row
Section titled “A real table under a row”▶ See it working: nested tables in Mantine — open a person onto their orders; the inner table is the same component. The same page exists for MUI, Chakra, antd, Radix, Base UI, shadcn and Tailwind.
renderRowDetail gives a blank panel, and a table built by hand in one has none
of the sorting, filtering, selection, keyboard navigation or accessibility the
outer table has. nestedTable puts the same component in there instead:
<DataTable data={people} columns={columns} rowKey={(row) => row.id} nestedTable={(row) => ({ label: `Orders for ${row.name}`, table: (defaults) => ( <DataTable {...defaults} data={row.orders} columns={orderColumns} rowKey={(order) => order.id} /> ), })}/>The child rows keep their own type — the closure holds it, so nothing is erased
at a prop boundary — and the row gets the same expand chevron renderRowDetail
gives it.
defaults carries what a table inside a row cannot do without: urlSync: false
(two tables writing ?page= fight over one URL, and the loser silently resets
while the reader is using it), searchable: false (a second search box inside a
row reads as chrome, not as a feature — pass searchable after the spread to
keep it), and the parent’s density and labels. Spread them first and override
anything after.
Return undefined for a row with no nested table. With renderRowDetail also
set, those rows fall back to it, so master/detail and nested tables can live in
one table. The region carries data-adapttable-part="nested-table" and is
announced with the label you gave it. defaultExpandedRowIds opens those rows
on the first render so the inner table is visible without a click.
Headless pieces
Section titled “Headless pieces”Everything above is composed from exports you can use directly when you render your own body:
buildTreeEntries(options: BuildTreeEntriesOptions<TRow>)— flattens a hierarchy intoTreeEntry<TRow>[]in reading order. A collapsed node contributes itself and nothing beneath it. Each entry carrieslevel,hasChildren,expanded,path,descendantIds(the whole subtree, folded or not, so ticking a folder can tick what is in it) andloading.TreeShape<TRow>— the three ways a host declares the hierarchy:getChildren,getParentId, andhasChildrenfor children that have not been fetched yet.useTreeExpansion(options)→TreeExpansionState— the open set, controlled or not, withtoggle,expand,collapse,isExpanded.filterTreeRows(options)— keeps matching rows and every ancestor that leads to one, so a filtered tree still has a path to what it found.treeColumnKey(columns, declared?)— which column carries the chevron.treeIndentStyle(level)— the cell’s indent (paddingInlineStart).treeCardStyle(level)— the card’s indent (marginInlineStart).bodyRowEntries(rows, tree)→BodyRowEntry<TRow>[]— the rows a body renders: the tree’s visible entries when a tree is armed, the windowed rows otherwise. One.mapeither way.TreeCell(on each adapter, propsTreeCellProps<TRow>) — wraps the tree column’s cell in its chevron and its indent, and passes every other column through untouched. The indent goes on the wrapper around the content, so a name moves with its depth rather than sitting at the same margin as its parent’s. Layout isTreeCellChromeon@adapttable/core/adapter.TreeToggle(on each adapter, propsTreeToggleProps<TRow>) — the chevron itself:aria-expanded, a localized accessible name, and an equal-width spacer on a leaf. Layout isTreeToggleChrome.
Styling hooks
Section titled “Styling hooks”@adapttable/unstyled and @adapttable/shadcn stamp data-adapttable-part on
each piece: tree-cell (the indented wrapper), tree-toggle (the chevron), and
tree-spacer (a leaf’s placeholder). A node whose children are loading carries
data-loading on its toggle.