You're building an org chart. Each company has divisions. Each division has departments. Each department has teams. Users need to drill down through this hierarchy, expanding and collapsing as they explore. Displaying this kind of nested, parent-child data in a table is called tree data or hierarchical data display.
Unlike flat tables where every row is independent, tree data requires understanding relationships: which rows are parents, which are children, how deep the nesting goes, and which branches are expanded or collapsed. Get it wrong, and you end up with a confusing mess. Get it right, and users can navigate complex data structures intuitively.
In this guide, we'll cover how to implement tree data in React tables, common patterns for structuring hierarchical data, performance considerations for deep nesting, and advanced techniques like lazy loading child nodes on demand.
Why Tree Data Matters
Hierarchical data is everywhere in business applications:
Organization Charts
Companies → Divisions → Departments → Teams → Employees. Multiple levels of reporting structure need expandable visualization.
Project Management
Projects → Milestones → Tasks → Subtasks. Track progress across nested work items with clear parent-child relationships.
Category Hierarchies
Product catalogs, file systems, menu navigation—any data with categories, subcategories, and items.
Financial Reporting
Accounts → Sub-accounts → Transactions. Display nested financial data with rollup totals at each level.
Without proper tree data support, developers resort to hacky workarounds: separate tables for each level, custom expand/collapse logic, manual indentation with CSS. These approaches break down with deep nesting or dynamic data. That's why full-featured data grids include built-in row grouping.
Understanding Tree Data Structure
Tree data in React tables uses nested arrays to represent parent-child relationships. Each parent row contains an array of child rows, which can themselves contain arrays of grandchildren, and so on.
Simple Two-Level Example
1// Companies → Departments (2 levels)2const data = [3 {4 id: "company-1",5 name: "TechCorp",6 employees: 250,7 revenue: "$25M",8 // Children stored in 'departments' array9 departments: [10 {11 id: "dept-1",12 name: "Engineering",13 employees: 120,14 revenue: "$15M",15 },16 {17 id: "dept-2",18 name: "Sales",19 employees: 80,20 revenue: "$8M",21 },22 {23 id: "dept-3",24 name: "Marketing",25 employees: 50,26 revenue: "$2M",27 },28 ],29 },30 {31 id: "company-2",32 name: "HealthPlus",33 employees: 180,34 revenue: "$18M",35 departments: [36 {37 id: "dept-4",38 name: "Medical",39 employees: 100,40 revenue: "$12M",41 },42 {43 id: "dept-5",44 name: "Research",45 employees: 80,46 revenue: "$6M",47 },48 ],49 },50];
Multi-Level Deep Nesting
1// Companies → Divisions → Departments → Teams (4 levels)2const data = [3 {4 id: "company-1",5 name: "TechCorp",6 employees: 500,7 // Level 1: Divisions8 divisions: [9 {10 id: "div-1",11 name: "Engineering Division",12 employees: 300,13 // Level 2: Departments14 departments: [15 {16 id: "dept-1",17 name: "Frontend Department",18 employees: 100,19 // Level 3: Teams20 teams: [21 {22 id: "team-1",23 name: "React Team",24 employees: 30,25 },26 {27 id: "team-2",28 name: "Vue Team",29 employees: 25,30 },31 ],32 },33 {34 id: "dept-2",35 name: "Backend Department",36 employees: 120,37 teams: [38 {39 id: "team-3",40 name: "API Team",41 employees: 40,42 },43 {44 id: "team-4",45 name: "Database Team",46 employees: 35,47 },48 ],49 },50 ],51 },52 ],53 },54];
Key Pattern
Each level of the hierarchy uses a different array property name (e.g., divisions, departments, teams). This tells the table which property contains the next level of children.
Implementation with Simple Table
Step 1: Mark the Expandable Column
First, tell Simple Table which column should show the expand/collapse controls:
1import { SimpleTable, HeaderObject } from "simple-table-core";2import "simple-table-core/styles.css";34const headers: HeaderObject[] = [5 {6 accessor: "name",7 label: "Organization Name",8 width: 250,9 expandable: true, // This column gets expand/collapse controls10 type: "string",11 },12 {13 accessor: "employees",14 label: "Employees",15 width: 120,16 type: "number",17 },18 {19 accessor: "revenue",20 label: "Revenue",21 width: 150,22 type: "string",23 },24];
Step 2: Define the Hierarchy Levels
Use the rowGrouping prop to specify the array property names for each nesting level:
1// Two levels: Companies → Departments2<SimpleTable3 defaultHeaders={headers}4 rows={data}5 rowIdAccessor="id"6 rowGrouping={["departments"]} // Array property name for children7/>89// Three levels: Companies → Divisions → Departments10<SimpleTable11 defaultHeaders={headers}12 rows={data}13 rowIdAccessor="id"14 rowGrouping={["divisions", "departments"]} // Order = nesting depth15/>1617// Four levels: Companies → Divisions → Departments → Teams18<SimpleTable19 defaultHeaders={headers}20 rows={data}21 rowIdAccessor="id"22 rowGrouping={["divisions", "departments", "teams"]}23/>
How it Works
Simple Table reads the rowGrouping array to understand your hierarchy:
- First element (
"divisions") = Level 1 children - Second element (
"departments") = Level 2 children - Third element (
"teams") = Level 3 children
Step 3: Control Initial Expand State
1// Start with all rows collapsed2<SimpleTable3 expandAll={false}4 // ... other props5/>67// Start with all rows expanded (default)8<SimpleTable9 expandAll={true}10 // ... other props11/>
Complete Example
1import { SimpleTable, HeaderObject } from "simple-table-core";2import "simple-table-core/styles.css";34const headers: HeaderObject[] = [5 {6 accessor: "organization",7 label: "Organization",8 width: 200,9 expandable: true,10 type: "string"11 },12 { accessor: "employees", label: "Employees", width: 100, type: "number" },13 { accessor: "budget", label: "Budget", width: 140, type: "string" },14 { accessor: "location", label: "Location", width: 130, type: "string" },15];1617const data = [18 {19 id: "company-1",20 organization: "TechSolutions Inc.",21 employees: 137,22 budget: "$15.0M",23 location: "San Francisco",24 divisions: [25 {26 id: "div-100",27 organization: "Engineering Division",28 employees: 97,29 budget: "$10.6M",30 location: "Multiple",31 departments: [32 {33 id: "dept-1001",34 organization: "Frontend",35 employees: 28,36 budget: "$2.8M",37 location: "San Francisco",38 },39 {40 id: "dept-1002",41 organization: "Backend",42 employees: 32,43 budget: "$3.4M",44 location: "Seattle",45 },46 ],47 },48 {49 id: "div-101",50 organization: "Product Division",51 employees: 40,52 budget: "$4.4M",53 location: "Multiple",54 departments: [55 {56 id: "dept-1101",57 organization: "Design",58 employees: 17,59 budget: "$1.8M",60 location: "Portland",61 },62 ],63 },64 ],65 },66];6768export default function OrgChartTable() {69 return (70 <SimpleTable71 defaultHeaders={headers}72 rows={data}73 rowIdAccessor="id"74 rowGrouping={["divisions", "departments"]}75 expandAll={false}76 height="600px"77 />78 );79}
Lazy Loading: Load Children On-Demand
For large hierarchies, loading all data upfront is slow and memory-intensive. Instead, load only top-level rows initially, then fetch children when users expand a parent. This pattern works especially well with pagination for even better performance.
Using onRowGroupExpand Callback
1import { SimpleTable, HeaderObject, OnRowGroupExpandProps } from "simple-table-core";2import { useState } from "react";34export default function LazyTreeTable() {5 const [data, setData] = useState([6 // Initial data: only top-level rows, no children yet7 {8 id: "region-1",9 name: "North America",10 count: 150,11 // No 'stores' array yet - will be loaded on expand12 },13 {14 id: "region-2",15 name: "Europe",16 count: 200,17 },18 ]);1920 const handleExpand = async ({21 row,22 rowId,23 depth,24 groupingKey,25 isExpanded,26 setLoading,27 setError,28 setEmpty,29 rowIndexPath,30 }: OnRowGroupExpandProps) => {31 // Only load when expanding (not collapsing)32 if (!isExpanded) return;3334 // Set loading state in the UI35 setLoading(true);3637 try {38 // Fetch children from API39 const response = await fetch(`/api/tree-data?parent=${rowId}&level=${groupingKey}`);40 const children = await response.json();4142 setLoading(false);4344 if (children.length === 0) {45 setEmpty(true, "No items found");46 return;47 }4849 // Update data with children50 setData((prevData) => {51 const newData = [...prevData];52 // rowIndexPath provides exact location: [0] means first row53 newData[rowIndexPath[0]][groupingKey] = children;54 return newData;55 });56 } catch (error) {57 setLoading(false);58 setError("Failed to load data");59 }60 };6162 return (63 <SimpleTable64 defaultHeaders={headers}65 rows={data}66 rowIdAccessor="id"67 rowGrouping={["stores", "products"]}68 onRowGroupExpand={handleExpand}69 loadingStateRenderer="Loading..."70 errorStateRenderer="Failed to load"71 emptyStateRenderer="No data available"72 />73 );74}
Benefits of Lazy Loading
- Faster Initial Load: Only fetch top-level data, not entire tree
- Reduced Memory: Children only loaded when needed
- Better Performance: Scales to massive hierarchies (1000s of nodes)
- Seamless UX: Loading states keep users informed
rowIndexPath: Simplified Updates
The rowIndexPath array tells you exactly where to insert children:
1// Example: Expanding "Backend" department (3rd item in divisions array)2// rowIndexPath = [0, "divisions", 2]3// Meaning: rows[0].divisions[2] = Backend department45// Update is simple:6setData(prev => {7 const newData = [...prev];8 newData[rowIndexPath[0]][groupingKey] = children;9 return newData;10});1112// For deeper nesting, traverse the path:13// rowIndexPath = [0, "divisions", 1, "departments", 2]14// Meaning: rows[0].divisions[1].departments[2]1516let target = newData[rowIndexPath[0]];17for (let i = 1; i < rowIndexPath.length - 1; i += 2) {18 const key = rowIndexPath[i];19 const index = rowIndexPath[i + 1];20 target = target[key][index];21}22target[groupingKey] = children;
Common Use Cases & Patterns
Organization Chart
1// Structure: Company → Divisions → Departments → Teams2rowGrouping={["divisions", "departments", "teams"]}34// Example Row5{6 id: "company-1",7 name: "TechCorp",8 headcount: 500,9 divisions: [10 {11 id: "div-1",12 name: "Engineering",13 headcount: 300,14 departments: [...]15 }16 ]17}
Project Management
1// Structure: Projects → Milestones → Tasks → Subtasks2rowGrouping={["milestones", "tasks", "subtasks"]}34// Example Row5{6 id: "project-1",7 name: "Website Redesign",8 status: "In Progress",9 milestones: [10 {11 id: "milestone-1",12 name: "Design Phase",13 progress: "80%",14 tasks: [15 {16 id: "task-1",17 name: "Create Wireframes",18 assignee: "Sarah",19 subtasks: [...]20 }21 ]22 }23 ]24}
Product Catalog
1// Structure: Categories → Subcategories → Products2rowGrouping={["subcategories", "products"]}34// Example Row5{6 id: "cat-1",7 name: "Electronics",8 itemCount: 1250,9 subcategories: [10 {11 id: "subcat-1",12 name: "Laptops",13 itemCount: 145,14 products: [15 {16 id: "prod-1",17 name: "MacBook Pro 16",18 price: "$2,499",19 stock: 4220 }21 ]22 }23 ]24}
Financial Accounts
1// Structure: Accounts → Sub-accounts → Transactions2rowGrouping={["subaccounts", "transactions"]}34// Example Row5{6 id: "account-1",7 name: "Operating Account",8 balance: "$125,000",9 subaccounts: [10 {11 id: "subaccount-1",12 name: "Payroll",13 balance: "$85,000",14 transactions: [15 {16 id: "trans-1",17 date: "2025-12-01",18 description: "Employee Salaries",19 amount: "-$75,000"20 }21 ]22 }23 ]24}
Performance Optimization Tips
✅ Use Lazy Loading for Deep Hierarchies
Don't load all 10,000 nodes upfront. Use onRowGroupExpand to fetch children only when parents are expanded. Initial load stays fast even with massive trees.
✅ Start Collapsed for Large Trees
Set expandAll= when you have 100+ top-level rows or deep nesting (4+ levels). Let users expand what they need rather than rendering thousands of rows at once.
✅ Virtualization Is Built-In
Simple Table's virtualization works with tree data. Even if you expand to 5,000 visible rows, only ~30 DOM nodes are rendered at any time. Scrolling remains smooth.
✅ Stable Row IDs Are Critical
Use unique, stable IDs for every row at every level (rowIdAccessor). This prevents React re-renders when expanding/collapsing and keeps expand state consistent.
❌ Avoid: Loading Entire Tree Upfront
Don't fetch all levels of a 10,000-node tree on page load. Use lazy loading. Your backend should support /api/tree?parent=123 to fetch children on demand.
❌ Avoid: Complex Transforms in Render
Don't flatten/transform tree data inside your component render. Do it once when data loads, then pass the structured tree to Simple Table. Repeating transforms on every render tanks performance.
Tree Data Made Simple
Tree data doesn't have to be complicated. With the right structure—nested arrays with clear property names—and the right API—expandable columns and rowGrouping configuration—hierarchical data becomes as easy to display as flat tables.
Key takeaways:
- Structure data with nested arrays using clear property names for each hierarchy level
- Mark one column as expandable to show expand/collapse controls
- Define hierarchy with rowGrouping array to tell the table which properties contain children
- Use lazy loading for large trees via
onRowGroupExpandto load children on demand - Start collapsed for deep hierarchies with
expandAll=
Whether you're building org charts, project trackers, file explorers, or financial reports, tree data is a fundamental pattern. With Simple Table's row grouping, you get expandable hierarchies out of the box—no custom logic, no complex state management, just structured data and clear configuration.