Skip to content

Tree Table

Display hierarchical data with expandable parent and child rows.

Open in
Preview with Controlled State
Open in

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.

  1. Add the required components:
npx shadcn@latest add table input button dropdown-menu checkbox tooltip
  1. Add tanstack/react-table dependency:
npm install @tanstack/react-table
  1. Copy the DataTable components into your project. See the Installation Guide for detailed instructions.

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,
DataTableColumnTitle,
DataTableColumnSortMenu,
} from "@/components/niko-table/components"
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,
DataTable,
DataTableHeader,
DataTableBody,
DataTableEmptyBody,
} from "@/components/niko-table/core"
import {
DataTableToolbarSection,
DataTablePagination,
DataTableSearchFilter,
DataTableViewMenu,
DataTableColumnHeader,
DataTableColumnTitle,
DataTableColumnSortMenu,
} from "@/components/niko-table/components"
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. See the Tree Table example for a complete implementation.

✅ 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)