| 2023-07-28

Building an Expandable Table with React Table

    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.

    "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";
    
    javascript
    1. Provide data to the table.
    export type Item = {
      id: string,
      name: string,
      parent: string | null | undefined,
      children?: Item[],
    };
    
    jsx

    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:

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

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

      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,
              },
            ],
          },
        ],
        []);
      
      jsx

      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:

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

      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:

      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>
      );
      
      jsx

    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.


    Thanks for reading. If you enjoyed this post, I invite you to explore more of my site. I write about web development, programming, and other fun stuff.