Skip to content
Niko Table

Tree Table

Display hierarchical data with expandable parent and child rows.

Renders nested row models (subRows) with collapsible parents and optional cascading selection — checking a parent flips every descendant, and partial child selection bubbles up as indeterminate. Backed by TanStack Table’s expanded-row model, so there’s no recursive code in your column defs.

Open in
Project Name
Status
Budget
Website Redesign
active
$50,000
Mobile App
active
$80,000
Database Migration
completed
$30,000
API Development
active
$45,000
Preview with Controlled State
Open in
Project Name
Status
Budget
Website Redesign
active
$50,000
Mobile App
active
$80,000
Database Migration
completed
$30,000
API Development
active
$45,000
Tree Table State
Live view of the tree table state with hierarchical data and selection
Search Query:None
Total Nodes:13
Parent Nodes:4
Leaf Nodes:9
Max Depth:3
Selected Rows:0
Selected Budget:$0
Expanded Nodes:0
Sorting:None
Page:1 (Size: 10)
Hidden Columns:0
View Full State Object
Row Selection:
{}
Expanded State:
{}
Sorting:
[]
Pagination:
{
  "pageIndex": 0,
  "pageSize": 10
}
Column Visibility:
{}

The Tree Table displays hierarchical data with parent and child relationships. Rows can be expanded to show nested data, and selection can cascade to children.

Both expand/collapse, but they are different features — use one per table:

Tree Grouping Table
Source Nested data via getSubRows Flat data + column buckets
Who defines structure Your data model User / grouping state
Aggregations Not the main model aggregationFn / aggregatedCell
Typical UI Indent + chevron per level Group by in the column menu + (n) count

Row Expansion is a third pattern: one flat row opens an inline detail panel — not nested rows and not column buckets.

Install the DataTable core and add-ons for this example:

pnpm dlx shadcn@latest add @niko-table/data-table @niko-table/data-table-selection-bar @niko-table/data-table-pagination @niko-table/data-table-search-filter @niko-table/data-table-view-menu @niko-table/data-table-column-sort

This example also uses checkbox from Shadcn UI:

pnpm dlx shadcn@latest add checkbox

First time using @niko-table? See the Installation Guide to set up the registry.

For other add-ons or manual copy-paste, see the Installation Guide.

We are going to build a table to show projects with sub-projects. Here’s what our data looks like:

type Project = {
id: string
name: string
status: "active" | "completed" | "on-hold"
budget: number
subRows?: Project[]
}
const data: Project[] = [
{
id: "1",
name: "Website Redesign",
status: "active",
budget: 50000,
subRows: [
{
id: "1-1",
name: "UI/UX Design",
status: "completed",
budget: 15000,
},
{
id: "1-2",
name: "Frontend Development",
status: "active",
budget: 25000,
},
],
},
// ...
]

Let’s start by building a tree table.

First, we’ll define our columns with tree visualization.

columns.tsx
"use client"
import { DataTableColumnHeader } from "@/components/niko-table/components/data-table-column-header"
import { DataTableColumnTitle } from "@/components/niko-table/components/data-table-column-title"
import { DataTableColumnSortMenu } from "@/components/niko-table/components/data-table-column-sort"
import type { DataTableColumnDef } from "@/components/niko-table/types"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { ChevronRight, ChevronDown } from "lucide-react"
export type Project = {
id: string
name: string
status: "active" | "completed" | "on-hold"
budget: number
subRows?: Project[]
}
export const columns: DataTableColumnDef<Project>[] = [
{
accessorKey: "name",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
meta: { label: "Project Name" },
cell: ({ row }) => {
const { depth, original: project } = row
const canExpand = row.getCanExpand()
const isExpanded = row.getIsExpanded()
return (
<div className="flex items-center gap-2">
{/* Tree visualization */}
<div className="flex items-center">
{depth > 0 && (
<div className="flex h-6 w-6 items-center justify-center">
{/* Tree lines would go here */}
</div>
)}
{canExpand && (
<Button
variant="ghost"
size="sm"
onClick={row.getToggleExpandedHandler()}
className="h-4 w-4 p-0"
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
<div className="font-medium">{row.getValue("name")}</div>
</div>
)
},
},
{
accessorKey: "status",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
meta: { label: "Status" },
},
{
accessorKey: "budget",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
meta: { label: "Budget" },
cell: ({ row }) => {
const budget = row.getValue("budget") as number
return <div className="font-mono">${budget.toLocaleString()}</div>
},
},
]

Next, we’ll create the tree table with getSubRows.

tree-table.tsx
"use client"
import * as React from "react"
import { DataTableRoot } from "@/components/niko-table/core/data-table-root"
import { DataTable } from "@/components/niko-table/core/data-table"
import {
DataTableHeader,
DataTableBody,
DataTableEmptyBody,
} from "@/components/niko-table/core/data-table-structure"
import { DataTableToolbarSection } from "@/components/niko-table/components/data-table-toolbar-section"
import { DataTablePagination } from "@/components/niko-table/components/data-table-pagination"
import { DataTableSearchFilter } from "@/components/niko-table/components/data-table-search-filter"
import { DataTableViewMenu } from "@/components/niko-table/components/data-table-view-menu"
import { DataTableColumnHeader } from "@/components/niko-table/components/data-table-column-header"
import { DataTableColumnTitle } from "@/components/niko-table/components/data-table-column-title"
import { DataTableColumnSortMenu } from "@/components/niko-table/components/data-table-column-sort"
import type { DataTableColumnDef } from "@/components/niko-table/types"
import { Button } from "@/components/ui/button"
import {
ChevronRight,
ChevronDown,
ChevronsDownUp,
ChevronsUpDown,
} from "lucide-react"
type Project = {
id: string
name: string
status: "active" | "completed" | "on-hold"
budget: number
subRows?: Project[]
}
const columns: DataTableColumnDef<Project>[] = [
{
accessorKey: "name",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
meta: { label: "Project Name" },
cell: ({ row }) => {
const canExpand = row.getCanExpand()
const isExpanded = row.getIsExpanded()
return (
<div className="flex items-center gap-2">
{canExpand && (
<Button
variant="ghost"
size="sm"
onClick={row.getToggleExpandedHandler()}
className="h-4 w-4 p-0"
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</Button>
)}
<div className="font-medium">{row.getValue("name")}</div>
</div>
)
},
},
{
accessorKey: "status",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
meta: { label: "Status" },
},
{
accessorKey: "budget",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
meta: { label: "Budget" },
cell: ({ row }) => {
const budget = row.getValue("budget") as number
return <div className="font-mono">${budget.toLocaleString()}</div>
},
},
]
export function TreeTable({ data }: { data: Project[] }) {
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({})
const expandAll = () => {
const expandedRows: Record<string, boolean> = {}
const expandProjects = (projects: Project[]) => {
for (const project of projects) {
if (project.subRows?.length) {
expandedRows[project.id] = true
expandProjects(project.subRows)
}
}
}
expandProjects(data)
setExpanded(expandedRows)
}
const collapseAll = () => {
setExpanded({})
}
return (
<DataTableRoot
data={data}
columns={columns}
config={{
enableExpanding: true,
enableFilters: true,
}}
state={{
expanded,
}}
onExpandedChange={setExpanded}
getSubRows={row => row.subRows}
getRowCanExpand={row => Boolean(row.original.subRows?.length)}
getRowId={row => row.id}
>
<DataTableToolbarSection>
<DataTableSearchFilter placeholder="Search projects..." />
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={expandAll}>
<ChevronsDownUp className="mr-2 h-4 w-4" />
Expand All
</Button>
<Button variant="outline" size="sm" onClick={collapseAll}>
<ChevronsUpDown className="mr-2 h-4 w-4" />
Collapse All
</Button>
<DataTableViewMenu />
</div>
</DataTableToolbarSection>
<DataTable>
<DataTableHeader />
<DataTableBody>
<DataTableEmptyBody />
</DataTableBody>
</DataTable>
<DataTablePagination />
</DataTableRoot>
)
}

Tell TanStack Table how to access child rows:

<DataTableRoot
data={data}
columns={columns}
getSubRows={row => row.subRows}
getRowCanExpand={row => Boolean(row.original.subRows?.length)}
getRowId={row => row.id}
>
{/* ... */}
</DataTableRoot>

Access row depth in cell renderers:

cell: ({ row }) => {
const { depth } = row
// depth: 0 = top level, 1 = first child, 2 = second level child, etc.
return <div style={{ paddingLeft: `${depth * 20}px` }}>...</div>
}

Control expansion state:

const [expanded, setExpanded] = useState<Record<string, boolean>>({})
const expandAll = () => {
const expandedRows: Record<string, boolean> = {}
const expandProjects = (projects: Project[]) => {
for (const project of projects) {
if (project.subRows?.length) {
expandedRows[project.id] = true
expandProjects(project.subRows)
}
}
}
expandProjects(data)
setExpanded(expandedRows)
}
const collapseAll = () => {
setExpanded({})
}

Manage all table state externally for full control:

import { useState } from "react"
import type {
ExpandedState,
SortingState,
ColumnFiltersState,
} from "@tanstack/react-table"
export function ControlledTreeTable({ data }: { data: Project[] }) {
const [expanded, setExpanded] = useState<ExpandedState>({})
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [globalFilter, setGlobalFilter] = useState("")
const expandAll = () => {
const expandedRows: Record<string, boolean> = {}
const expandProjects = (projects: Project[]) => {
for (const project of projects) {
if (project.subRows?.length) {
expandedRows[project.id] = true
expandProjects(project.subRows)
}
}
}
expandProjects(data)
setExpanded(expandedRows)
}
const collapseAll = () => {
setExpanded({})
}
return (
<DataTableRoot
data={data}
columns={columns}
config={{
enableExpanding: true,
enableFilters: true,
}}
state={{
expanded,
sorting,
columnFilters,
globalFilter,
}}
onExpandedChange={setExpanded}
onSortingChange={setSorting}
onColumnFiltersChange={setColumnFilters}
onGlobalFilterChange={setGlobalFilter}
getSubRows={row => row.subRows}
getRowCanExpand={row => Boolean(row.original.subRows?.length)}
getRowId={row => row.id}
>
<DataTableToolbarSection>
<DataTableSearchFilter placeholder="Search projects..." />
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={expandAll}>
<ChevronsDownUp className="mr-2 h-4 w-4" />
Expand All
</Button>
<Button variant="outline" size="sm" onClick={collapseAll}>
<ChevronsUpDown className="mr-2 h-4 w-4" />
Collapse All
</Button>
<DataTableViewMenu />
</div>
</DataTableToolbarSection>
<DataTable>
<DataTableHeader />
<DataTableBody>
<DataTableEmptyBody />
</DataTableBody>
</DataTable>
<DataTablePagination />
</DataTableRoot>
)
}

For hierarchical selection (selecting a parent selects all children), you’ll need custom selection logic — the live demo at the top of this page implements it; open its Code tab for the cascading-selection column.

✅ Use Tree Table when:

  • You have hierarchical data (parent-child relationships)
  • You need to show nested structures
  • Users need to expand/collapse sections
  • Data has multiple levels of nesting

❌ Don’t use Tree Table when: