Creating Expandable Tree Structured Tables with react-table
react-table
is a powerful library for building customisable and performant tables in React applications. It provides a comprehensive set of features, including sorting, filtering, pagination, and even support for hierarchical data structures. In this blog, we'll explore how to create tree-like tables using react-table and enable expandable rows to reveal nested data.
Prerequisites
To follow along, you'll need the following:
A basic understanding of
React
Familiarity with
ES6+ JavaScript
syntaxNode.js
andnpm
installed on your systemOptional: Basic understanding of
TypeScript
as all of the code is in TypeScript but even if you don't know TypeScript you'll still be able to understand the code.
Setting up the Project
Create a new
react
project usingCreate React App
or your preferred method. You can also usenext.js
if you want.Install the
@tanstack/react-table
package usingnpm
oryarn
:
npm i @tanstack/react-table
- Create a Table component. This component will take columns and data as the props, but we'll leave that for later. For now, let's just create a boilerplate component.
import React from "react";
type Props = {};
const Table = (props: Props) => {
return <div>Table<div>;
}
export default Table;
Defining columns
react-table
requires defining the columns and data i.e. the data to display in the table. You can say this is the most important step in creating a table with react-table, because if not done correctly this can cause many problems in our table. But no need to worry, it's not difficult at all. I'm going to use the example of a family tree for this table, you can use something else if you want, it won't affect the flow of creating the table. Let's start by creating some dummy data for our family tree.
Create a file called tableData.ts
and define your data in it. Don't forget to export it!
export const tableData = [
{
name: "Pelé",
age: 82,
gender: "Male",
bloodType: "O+",
children: [
{
name: "Kely Nascimento",
age: 55,
gender: "Female",
bloodType: "A-",
children: [
{
name: "Arthur Arantes do Nascimento",
age: 13,
gender: "Male",
bloodType: "AB+"
}, ]
},
{
name: "Edson Cholby Nascimento",
age: 52,
gender: "Male",
bloodType: "B+"
}
]
},
{
name: "Diego Maradona",
age: 60,
gender: "Male",
bloodType: "O+",
children: [
{
name: "Gianinna Maradona",
age: 32,
gender: "Female",
bloodType: "A-",
children: [
{
name: "Diego Fernando Maradona Sinagra",
age: 9,
gender: "Male",
bloodType: "AB+"
},
]
},
{
name: "Jana Maradona",
age: 25,
gender: "Female",
bloodType: "AB+",
children: [
{
name: "Diego Maradona Jr.",
age: 3,
gender: "Male",
bloodType: "AB-"
}
]
}
]
},
{
name: "Zinedine Zidane",
age: 50,
gender: "Male",
bloodType: "A-",
children: [
{
name: "Enzo Zidane",
age: 27,
gender: "Male",
bloodType: "O-"
},
{
name: "Luca Zidane",
age: 25,
gender: "Male",
bloodType: "AB-"
}
]
},
{
name: "Cristiano Ronaldo",
age: 38,
gender: "Male",
bloodType: "AB-",
children: [
{
name: "Cristiano Ronaldo Jr.",
age: 12,
gender: "Male",
bloodType: "B+",
children: []
},
{
name: "Alana Martina Dos Santos Aveiro",
age: 5,
gender: "Female",
bloodType: "B+"
},
{
name: "Eva Maria Dos Santos Aveiro",
age: 5,
gender: "Female",
bloodType: "B+"
},
]
},
{
name: "Lionel Messi",
age: 35,
gender: "Male",
bloodType: "O-",
children: [
{
name: "Thiago Messi Roccuzzo",
age: 10,
gender: "Male",
bloodType: "A-",
children: []
},
{
name: "Mateo Messi Roccuzzo",
age: 8,
gender: "Male",
bloodType: "B-"
},
]
}
];
Now let's define the columns for our table data. Create a file called headers.ts
define your headers in it. Once again, don't forget to export them!
export const headers = [
{
header: "Name",
accessor: "name"
},
{
header: "Age",
accessor: "age"
},
{
header: "Gender",
accessor: "gender"
},
{
header: "Blood Type",
accessor: "bloodType"
}
];
Why the "accessor" field?
react-table
which field of the data object to access for a particular column. For example for the Blood Type
column, we'll need to access the bloodType
field in the data object. You'll find more details ahead.Since we have a clear schema for our data, let's create a type for it. You can skip this step if you are not using TypeScript. Create a types.ts
file and write the type for your data. But if you already have a types folder with other types from your project, I'd suggest you create a new file in that types folder for this.
export type FamilyMember = {
name: string;
age: number;
gender: string;
bloodType: string;
children?: FamilyMember[];
subRows?: FamilyMember[];
};
Why the "subRows" field?
react-table
requires each nested row to have a subRows
field which it uses to identify whether it has any children rows or not.Now let's use the data and columns in our React code. In your App.tsx
file (or whichever file you intend to render Table
component in), define the columns in the following way:
import React, { useMemo } from "react";
import { ColumnDef } from "@tanstack/react-table";
import { headers } from "./headers";
import { FamilyMember } from "./types";
import Table from "./Table";
export default function App() {
const columns = useMemo<ColumnDef<FamilyMember>[]>(() => {
return [
{
header: headers[0].header,
accessorKey: headers[0].accessor,
cell: ({ row, getValue }) => (
<div
className="expander"
style={{
paddingLeft: `${row.depth * 2}rem`,
}}
>
{row.getCanExpand() && (
<button
className="toggle-expanded"
{...{
onClick: row.getToggleExpandedHandler(),
}}
>
{row.getIsExpanded() ? (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
width="16"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="#777"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
width="16"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="#777"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
)}
</button>
)}
{getValue()}
</div>
),
},
...headers.slice(1).map((header) => {
return {
header: header.header,
accessorKey: header.accessor,
};
}),
];
}, []);
return (
<div className="App">
<Table />
</div>
)
}
Now as you can see, a lot is going on here. Let's start with the structure of the columns array. We are importing the headers array that we defined for the columns. The columns array takes ColumnDef which are objects that define the columns in a table. They specify headers, accessors, cells and many other options. In our column objects, we are using header
and accessorKey
to define the columns except for the first column which also takes a cell
value. Let's see what all of these are:
header
: The text that will be displayed in the header of the column.accessorKey
: The key that will be used to get the value of the cell for a given row from the table data.cell
: The function that will be used to render the cell for a given row of that column.accessorFn
: Although not used in our example, accessor functions are functions that are used to extract the value of a cell from a row object. They receive a single argument: the row object and return value that will be displayed in the cell.
The reason we're using the cell
property for the first column is because it'll be our "expander" column, meaning the cells in this column will hold the ability to expand their row and for that, we need to do more than just render the value of the cell. We need to add a button to handle the expansion too. The cell
property allows us to do exactly that so we can render some JSX inside the cell instead of just the cell value. That is why if you see the code you can notice that we're doing this only for the first column and not the rest of the columns. For the rest of the columns we're just specifying the header
and the accessorKey
which will be enough to display just the cell value.
Now let's try to understand the code inside the cell
property's function. The function has access to the row
that the cell is part of and the getValue
function that will return the value of the cell it is rendering. In the styling of the cell, we have used the row.depth
value to give paddingLeft
to the cells, meaning child rows will get more padding than the parent making it visibly clear that they are parent and child rows. row.depth
simply gives the depth of the row in the tree hierarchy, so the top-most parent has a depth of 0, its child has a depth of 1 and so on. Next up we're checking if the row can expand i.e. if the row has any nested rows by using the row.getCanExpand
function. If the row can expand, we are rendering a button that will call row.getToggleExpandedHandler()
to toggle the expansion of the row and based on the row's expanded state, we are rendering different icons for the button. We've used the Name
column for the expansion in the example.
The role of useMemo
useReactTable
hook (which we'll get to soon) that is used to create the table, takes the columns and if your component re-renders for some reason, a new columns array will be created if useMemo
isn't used and useReactTable
will recalculate the underlying logic unnecessarily. So if you do want to update the columns array based on some state change, specify that state in the dependency array of the useMemo
hook as you would in a useEffect call.Defining the table data
We have defined the columns to display in the table and which column will display which field of the data. Now it's time to define the actual data to be displayed in the table. It is very simple, and makes use of useMemo just like columns. But there is one simple thing that we need to do before we proceed and that is adding the subRows field to all the objects in our data. As mentioned earlier, to use expanding rows, react-table
requires each nested row to have a subRows
field which it uses to identify whether it has any children rows or not.
For now, we only have the children field in each of our rows, let's use it to add the subRows
field to the rows. Create a file called utils.ts
or if you already have a utils folder in your project add a new file to it and write the following function in it:
import { FamilyMember } from "./types";
export const convertToTableData = (data: FamilyMember[]) => {
return data.map((member) => {
if (!member.children?.length) return;
const newMember: FamilyMember = {
...member,
subRows: member.children!.map((child) => {
if (!child.children?.length) return child;
const newChild: FamilyMember = {
...child,
subRows: child.children
};
delete newChild.children;
return newChild;
})
};
delete newMember.children;
return newMember;
});
};
This function is looping over all the family members along with their children and if the member has any children, it's replacing the children
property with the subRows
property which we can use in our table.
Now let's use this function to define the data for our table. Import the above function along with the tableData
and Add the following code just below your columns code.
const data = useMemo(() => convertToTableData(tableData), []);
Remember the Table
component we created at the start? It's time to pass some props to it. Go to the table component and add the following code:
import { ColumnDef } from "@tanstack/react-table";
import { FamilyMember } from "./types";
type Props = {
columns: ColumnDef<FamilyMember>[];
data: (FamilyMember | undefined)[];
};
function Table({ columns, data }: TableProps) {
return <div>Table<div>;
}
export default Table;
Creating the table with useReactTable
Now let's get to the main part. We have defined the columns, we have defined the data, it's time to create the table with the useReactTable
hook. It is the primary hook used to manage and render a table. It provides a broad set of functionalities for building tables, including data management, state management, table generation, column configuration, interaction handling etc. Let's create our table with this hook.
Add the following code to the Table component:
import React, { useState } from "react";
import {
ExpandedState,
useReactTable,
getCoreRowModel,
getExpandedRowModel,
ColumnDef,
flexRender
} from "@tanstack/react-table";
import { FamilyMember } from "./types";
type Props = {
columns: ColumnDef<FamilyMember>[];
data: (FamilyMember | undefined)[];
};
function Table({ columns, data }: TableProps) {
const [expanded, setExpanded] = useState<ExpandedState>({});
const table = useReactTable({
data,
columns,
state: {
expanded
},
onExpandedChange: setExpanded,
getSubRows: (row) => row?.subRows,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel()
});
return <div>Table<div>;
}
export default Table;
We've created our table with the useReactTable
hook, we are passing the data and columns to it. We are also creating a state called expanded
which will contain all the expanded rows and we're setting the state using onExpandedChange
function. We're passing four functions to the hook, here's what they do:
OnExpandedChange
: Called when theexpanded
table state changes.getSubRows
: An optional function used to access the sub rows for any given row. If you are using nested rows(which we are), you will need to use this function to return the sub-rows object from the row.getCoreRowModel
: A required option which is a factory for a function that computes and returns the core row model for the table.getExpandedRowModel
: Responsible for returning the expanded row model. If this is not provided, the table will not expand rows. We can use the default exportedgetExpandedRowModel
function to get the expanded row model or implement our own. We are using the default one in the example.
Rendering the table
We've created the table & got our table instance, now it's time to render it. Add the following code in the return statement of the Table component:
return (
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<th key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => {
return (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => {
return (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
);
Let's understand the code. The thead
section is responsible for rendering the table headers. It iterates over the header groups and then individual headers within each group. In our case, there's only one header group. For each header, it renders the header content using the flexRender
function that provides flexibility in rendering the contents of table cells. It allows you to customise the rendering process and apply custom logic or formatting based on the context of each cell.
The tbody
section is responsible for rendering the table rows. It iterates over the table's rows and then individual cells within each row. For each cell, it renders the cell content using the flexRender
function.
It's very simple, the table instance generated from the useReactTable hook gives you the table headers and rows to iterate through and render, along with some customisation options. Just make sure to pass a unique key when you're iterating through the items, otherwise, your console will be full of warnings.
Styling
We've finished adding all the functionality that we needed, but this is how it looks right now:
I have to admit, this is one of the ugliest tables I've ever seen. Let's add some styling to make it not so ugly. I'm just going to use vanilla CSS for this because there's not much to do. Create a styles.css
file, and add the following code to it:
table {
width: 100%;
border-collapse: collapse;
}
td,
th {
text-align: start;
padding: 0.4rem;
border: 1px solid #c9c9c9;
font-size: 14px;
}
.expander {
text-align: start;
display: flex;
align-items: center;
}
.toggle-expanded {
cursor: pointer;
background: transparent;
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
Add the expander
class to the cell of our first column and the toggle-expanded
class to the toggle button.
Here's how our final table looks now, not the greatest table of all time but much better than before and it works!
Conclusion
We've successfully expanded the capabilities of your table using the react-table
library and converted it into a tree-structured table. Feel free to experiment, customise, and explore additional features to tailor the table to your project's needs.
We've not only added functionality but also opened the door to a world of possibilities for presenting and interacting with hierarchical data. Continue building on this foundation, and happy coding!
Subscribe to my newsletter
Read articles from Ajinkya Palaskar directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Ajinkya Palaskar
Ajinkya Palaskar
A versatile frontend developer specialising in React and Next.js. Currently trying my hands on React Native. I also like working on Node.js every now and then.