Skip to content

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.

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:

  • Data is flat (use Basic Table)
  • You don’t need hierarchical structure
  • You prefer sidebars for details (use Aside Table)