Building an Expandable Table with React Table

2023-07-28
By: O. Wolfson

Introduction

This is an overview on how to build an Expandable Table using Next.js, React Table, and shadcn/ui UI components. shadcn/ui is a modern, simple, and customizable collection of UI components compatible with React and Tailwind CSS. It provides components that can be used to build beautiful and responsive user interfaces.

We will create an expandable data table where each row represents a record that can be expanded to show more detailed information about the record. This kind of table is useful in various scenarios, for example, in a file system where files are organized in a hierarchical structure. shadcn/ui provides a Table component, based on React (Tanstack) Table that makes it easy to create such tables.

We'll walk through the entire process of setting up the project, explaining the code, and providing code snippets to help you understand each step. By the end of this overview, you should have a clear understanding of how to React table and shadcn/ui together to create an expandable data table.

Link to example and source code.

You can preview the final product of this project here and the source code can be found on GitHub.

Here is an example of a React Table expandable table with pagination. and the source code.

Overview

  1. Create a Next.js project or other react based project. I am using Next.js 13 with Typescript and Tailwind CSS.

Install shadcn/ui and its peer dependencies used in its table component.

javascript
"use client";

import React, { useState } from "react";
import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons";
import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  useReactTable,
  ExpandedState,
  getExpandedRowModel,
} from "@tanstack/react-table";

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";

import { expandableData } from "./data";
  1. Provide data to the table.
jsx
export type Item = {
  id: string,
  name: string,
  parent: string | null | undefined,
  children?: Item[],
};

The above is the type definition for the data we'll be using in our table. Each item has an id, name, and parent property. The parent property is used to determine the hierarchy of the data. If an item has a parent property, it's a child of the item with the corresponding id. If an item has no parent property, it's a root item.

Here is a link to some sample data:

  1. useState Hooks: Here, we're using two hooks to store our data and the expanded state of our rows:

    jsx
    const [data, setData] = useState(() => expandableData);
    const [expanded, setExpanded] = useState < ExpandedState > {};
    

    data holds our table's data which initially comes from the expandableData object, and expanded holds the state of row expansion in our table.

  2. Columns Definition: The columns array defines the structure and behavior of our table. It uses the useMemo hook to ensure that the columns are only re-computed when necessary (in this case, they're static and never re-computed):

    jsx
    const columns =
      React.useMemo <
      ColumnDef <
      Item >
      [] >
      (() => [
        {
          header: "Record Store",
          footer: (props) => props.column.id,
          columns: [
            {
              accessorKey: "name",
              header: ({ table }) => (
                <>
                  <button
                    {...{
                      onClick: table.getToggleAllRowsExpandedHandler(),
                    }}
                  >
                    {table.getIsAllRowsExpanded() ? (
                      <ChevronDownIcon />
                    ) : (
                      <ChevronRightIcon />
                    )}
                  </button>{" "}
                  Items
                </>
              ),
              cell: ({ row, getValue }) => (
                <div
                  style={{
                    paddingLeft: `${row.depth * 2}rem`,
                  }}
                >
                  <>
                    {row.getCanExpand() ? (
                      <button
                        {...{
                          onClick: row.getToggleExpandedHandler(),
                          style: { cursor: "pointer" },
                        }}
                      >
                        {row.getIsExpanded() ? (
                          <div className="flex gap-2">
                            <ChevronDownIcon />
                            {getValue()}
                          </div>
                        ) : (
                          <div className="flex gap-2">
                            <ChevronRightIcon />
                            {getValue()}
                          </div>
                        )}{" "}
                      </button>
                    ) : (
                      <div>{getValue()}</div>
                    )}{" "}
                  </>
                </div>
              ),
              footer: (props) => props.column.id,
            },
          ],
        },
      ],
      []);
    

    Inside the columns array, we define one column with a nested column under it. The nested column uses the name property from each data item for its values (accessorKey: "name").

    The nested column header contains a button to expand or collapse all rows in the table. The button's icon changes based on whether all rows are expanded or not.

    The nested column cells also include buttons for expanding and collapsing individual rows. If a row is expandable (it has children), it displays a button with an icon that indicates whether the row is currently expanded or not. The row's depth in the data hierarchy determines its left padding.

  3. Use of useReactTable hook: The useReactTable hook creates a table model from our data and column definitions and provides functions and properties for interacting with the table:

    jsx
    const table = useReactTable({
      data,
      columns,
      state: {
        expanded,
      },
      onExpandedChange: setExpanded,
      getSubRows: (row) => row.children,
      getCoreRowModel: getCoreRowModel(),
      getFilteredRowModel: getFilteredRowModel(),
      getExpandedRowModel: getExpandedRowModel(),
      debugTable: true,
    });
    

    The useReactTable hook is called with an object that includes our data and columns, along with several other properties:

    • state: the state of the table (currently, we're only tracking expanded state).
    • onExpandedChange: a function to update the expanded state when a row's expanded state changes.
    • getSubRows: a function to determine a row's children, if any.
    • getCoreRowModel, getFilteredRowModel, getExpandedRowModel: functions to customize row models at various stages of processing.
    • debugTable: enables debug mode for additional logging.
  4. Rendering the Table: Lastly, we use the table model to generate our table's UI. We loop over the table.getHeaderGroups() to create headers for our table and table.getRowModel().rows to generate each row in the table body. If there are no rows to display, we show a "No results." message:

    jsx
    return (
      <div className="w-full">
        <div className="rounded-md border">
          <Table>
            <TableHeader>
              {table.getHeaderGroups().map((headerGroup) => (
                <TableRow key={headerGroup.id}>
                  {headerGroup.headers.map((header) => {
                    return (
                      <TableHead key={header.id}>
                        {header.isPlaceholder
                          ? null
                          : flexRender(
                              header.column.columnDef.header,
                              header.getContext()
                            )}
                      </TableHead>
                    );
                  })}
                </TableRow>
              ))}
            </TableHeader>
            <TableBody>
              {table.getRowModel().rows?.length ? (
                table.getRowModel().rows.map((row) => (
                  <TableRow
                    key={row.id}
                    data-state={row.getIsSelected() && "selected"}
                  >
                    {row.getVisibleCells().map((cell) => (
                      <TableCell key={cell.id}>
                        {flexRender(
                          cell.column.columnDef.cell,
                          cell.getContext()
                        )}
                      </TableCell>
                    ))}
                  </TableRow>
                ))
              ) : (
                <TableRow>
                  <TableCell
                    colSpan={columns.length}
                    className="h-24 text-center"
                  >
                    No results.
                  </TableCell>
                </TableRow>
              )}
            </TableBody>
          </Table>
        </div>
      </div>
    );
    

The above explanation should provide a comprehensive understanding of the DataTableDemo component's behavior and structure. However, you might need to tailor the details according to the specific use-case and data set you're working with.