Creating Expandable Tree Structured Tables with react-table

Ajinkya PalaskarAjinkya Palaskar
13 min read

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 syntax

  • Node.js and npm installed on your system

  • Optional: 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.

💡
If you just want to try this out or explore react-tables instead of using this functionality in your project, you can also follow along by using an online IDE like codesandbox.

Setting up the Project

  1. Create a new react project using Create React App or your preferred method. You can also use next.js if you want.

  2. Install the @tanstack/react-table package using npm or yarn:

npm i @tanstack/react-table
  1. 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?
When we define our columns, we need to tell 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?
In order 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.

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:

  1. header: The text that will be displayed in the header of the column.

  2. accessorKey: The key that will be used to get the value of the cell for a given row from the table data.

  3. cell: The function that will be used to render the cell for a given row of that column.

  4. 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
It is recommended to use useMemo because the 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:

  1. OnExpandedChange: Called when the expanded table state changes.

  2. 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.

  3. getCoreRowModel: A required option which is a factory for a function that computes and returns the core row model for the table.

  4. getExpandedRowModel: Responsible for returning the expanded row model. If this is not provided, the table will not expand rows. We can use the default exported getExpandedRowModel 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!

Not-so-ugly table

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!

Codesandbox - Final code

11
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.