iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article

How to Dynamically Generate Table Columns with TanStack Table v8

に公開

What is TanStack Table?

TanStack Table is a headless table library that can be used with frameworks such as React, Vue, Solid, and Svelte.

Key Features:

  • 🎨 Headless: UI-agnostic, allowing integration with any CSS framework or component library.
  • 📦 Lightweight: Tree-shaking support allows you to import only the features you need.
  • 🔧 Type-safe: Written in TypeScript, providing excellent type inference.
  • 🚀 High Performance: Optimized through virtualization and memoization.

💡 shadcn/ui's Data Table also uses TanStack Table under the hood.

Introduction (What this article covers)

In business systems, it is common to implement tables where the number and types of data change dynamically.
In this article, we will explain how to implement a table that meets the following requirements using TanStack Table (React Table) v8.

  • Dynamic Column Generation: The number of columns changes according to the number of data groups.
  • Group Headers: Display related columns grouped together.
  • TypeScript Support: Type-safe implementation.

Environment and Prerequisites

Technology Stack

Technology Version Purpose
React 18.x UI Library
TypeScript 5.x Type System
@tanstack/react-table 8.x Table Library

Installation

# npm
npm install @tanstack/react-table
# yarn
yarn add @tanstack/react-table
# pnpm
pnpm add @tanstack/react-table

Implementation Points

1. Dynamic Type Definitions

In TanStack Table, it is recommended to strictly define data types. However, when columns increase or decrease dynamically, you can use index signatures.

// Data type with dynamic keys
type DynamicRowData = {
  [key: string]: string | number | undefined;
};

// Specify the type in createColumnHelper
const columnHelper = createColumnHelper<DynamicRowData>();

2. Creating a Column Helper Function

Prepare a helper function to write column definitions concisely.

const createColumn = (id: string, header: string, isNumeric = false) => {
  return columnHelper.accessor(id, {
    cell: (info) => info.getValue(),
    header: header,
    meta: isNumeric ? { isNumeric: true } : undefined,
  });
};

Leveraging the meta Property

TanStack Table's meta can store arbitrary custom data. Here, we set an isNumeric flag, which can be used to apply styles such as right-alignment during rendering.

3. Dynamic Column Generation (useMemo)

Column definitions are memoized using useMemo and are recalculated only when the dependent data changes.

type Props = {
  mainData: RowData[];
  subGroups: RowData[][];  // Data groups that increase or decrease dynamically
};

const columns = useMemo(() => {
  if (!mainData || mainData.length === 0) {
    return [];
  }

  const cols: ColumnDef<DynamicRowData, string>[] = [];

  // 1️⃣ Fixed columns
  cols.push(
    columnHelper.group({
      id: "info",
      header: "",
      columns: [
        createColumn("name", "Name"),
        createColumn("code", "Code"),
      ],
    }),
  );

  // 2️⃣ Main data columns
  cols.push(
    columnHelper.group({
      id: "total",
      header: "Total",
      columns: [
        createColumn("count0", "Count", true),
        createColumn("amount0", "Amount", true),
      ],
    }),
  );

  // 3️⃣ Sub-group columns (dynamically generated)
  for (let i = 0; i < subGroups.length; i++) {
    cols.push(
      columnHelper.group({
        id: `group${i + 1}`,
        header: `Group ${i + 1}`,
        columns: [
          createColumn(`count${i + 1}`, "Count", true),
          createColumn(`amount${i + 1}`, "Amount", true),
        ],
      }),
    );
  }

  return cols;
}, [mainData, subGroups]);

4. Implementing Group Headers

Using columnHelper.group() allows you to group multiple columns together.

columnHelper.group({
  id: "group1",        // Unique ID
  header: "Group 1",   // Group header label
  columns: [           // Columns included in the group
    createColumn("count1", "Count", true),
    createColumn("amount1", "Amount", true),
  ],
});

Image of the rendering result:

┌──────────┬──────────┬────────────────┬────────────────┬────────────────┐
│          │          │      Total     │    Group 1     │    Group 2     │
├──────────┼──────────┼────────┬───────┼────────┬───────┼────────┬───────┤
│   Name   │   Code   │  Count │ Amount│  Count │ Amount│  Count │ Amount│
├──────────┼──────────┼────────┼───────┼────────┼───────┼────────┼───────┤
│   ...    │   ...    │  ...   │  ...  │  ...   │  ...  │  ...   │  ...  │

5. Data Transformation Process

Convert the original data into a flat format suitable for table display.

type RowData = {
  id: number;
  name: string;
  code: string;
  count: number;
  amount: number;
};

const data = useMemo(() => {
  if (!mainData || mainData.length === 0) {
    return [];
  }

  // Combine main data and subgroups
  const allGroups: RowData[][] = [mainData, ...subGroups];

  // Aggregate data using ID as the key
  const aggregated: Record<number, DynamicRowData> = {};

  allGroups.forEach((group, groupIndex) => {
    group.forEach((item) => {
      if (!(item.id in aggregated)) {
        aggregated[item.id] = {
          id: item.id,
          name: item.name,
          code: item.code,
        };
      }
      // Set values to the columns for each group
      aggregated[item.id][`count${groupIndex}`] = item.count;
      aggregated[item.id][`amount${groupIndex}`] = item.amount;
    });
  });

  return Object.values(aggregated);
}, [mainData, subGroups]);

6. Table Rendering

Create a table instance using the useReactTable hook and render cells with flexRender.

import {
  useReactTable,
  getCoreRowModel,
  flexRender,
} from "@tanstack/react-table";

const table = useReactTable({
  columns,
  data,
  getCoreRowModel: getCoreRowModel(),
});

return (
  <table>
    {/* Header rendering */}
    <thead>
      {table.getHeaderGroups().map((headerGroup) => (
        <tr key={headerGroup.id}>
          {headerGroup.headers.map((header) => (
            <th key={header.id} colSpan={header.colSpan}>
              {flexRender(
                header.column.columnDef.header,
                header.getContext()
              )}
            </th>
          ))}
        </tr>
      ))}
    </thead>

    {/* Body rendering */}
    <tbody>
      {table.getRowModel().rows.map((row) => (
        <tr key={row.id}>
          {row.getVisibleCells().map((cell) => (
            <td key={cell.id}>
              {flexRender(cell.column.columnDef.cell, cell.getContext())}
            </td>
          ))}
        </tr>
      ))}
    </tbody>
  </table>
);

Full Sample Code

Dynamic Column Table Component (Click to expand)
import { useMemo } from "react";
import {
  ColumnDef,
  createColumnHelper,
  useReactTable,
  getCoreRowModel,
  flexRender,
} from "@tanstack/react-table";

// Data type with dynamic keys
type DynamicRowData = {
  [key: string]: string | number | undefined;
};

// Original data type
type RowData = {
  id: number;
  name: string;
  code: string;
  count: number;
  amount: number;
};

const columnHelper = createColumnHelper<DynamicRowData>();

type Props = {
  mainData: RowData[];
  subGroups: RowData[][];
};

export const DynamicTable = ({ mainData, subGroups }: Props) => {
  // Column generation helper
  const createColumn = (id: string, header: string, isNumeric = false) => {
    return columnHelper.accessor(id, {
      cell: (info) => info.getValue(),
      header: header,
      meta: isNumeric ? { isNumeric: true } : undefined,
    });
  };

  // Dynamic column definition
  const columns = useMemo(() => {
    if (!mainData?.length) return [];

    const cols: ColumnDef<DynamicRowData, string>[] = [];

    // Fixed columns
    cols.push(
      columnHelper.group({
        id: "info",
        header: "",
        columns: [
          createColumn("name", "Name"),
          createColumn("code", "Code"),
        ],
      }),
    );

    // Total columns
    cols.push(
      columnHelper.group({
        id: "total",
        header: "Total",
        columns: [
          createColumn("count0", "Count", true),
          createColumn("amount0", "Amount", true),
        ],
      }),
    );

    // Sub-group columns (dynamic)
    subGroups.forEach((_, i) => {
      cols.push(
        columnHelper.group({
          id: `group${i + 1}`,
          header: `Group ${i + 1}`,
          columns: [
            createColumn(`count${i + 1}`, "Count", true),
            createColumn(`amount${i + 1}`, "Amount", true),
          ],
        }),
      );
    });

    return cols;
  }, [mainData, subGroups]);

  // Data transformation
  const data = useMemo(() => {
    if (!mainData?.length) return [];

    const allGroups: RowData[][] = [mainData, ...subGroups];
    const aggregated: Record<number, DynamicRowData> = {};

    allGroups.forEach((group, groupIndex) => {
      group.forEach((item) => {
        if (!(item.id in aggregated)) {
          aggregated[item.id] = {
            id: item.id,
            name: item.name,
            code: item.code,
          };
        }
        aggregated[item.id][`count${groupIndex}`] = item.count;
        aggregated[item.id][`amount${groupIndex}`] = item.amount;
      });
    });

    return Object.values(aggregated);
  }, [mainData, subGroups]);

  // Table instance
  const table = useReactTable({
    columns,
    data,
    getCoreRowModel: getCoreRowModel(),
  });

  if (data.length === 0) {
    return <p>No data available</p>;
  }

  return (
    <table>
      <thead>
        {table.getHeaderGroups().map((headerGroup) => (
          <tr key={headerGroup.id}>
            {headerGroup.headers.map((header) => (
              <th key={header.id} colSpan={header.colSpan}>
                {flexRender(
                  header.column.columnDef.header,
                  header.getContext()
                )}
              </th>
            ))}
          </tr>
        ))}
      </thead>
      <tbody>
        {table.getRowModel().rows.map((row) => (
          <tr key={row.id}>
            {row.getVisibleCells().map((cell) => (
              <td key={cell.id}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
};

Tips & Best Practices

1. Performance Optimization

// ❌ Bad: Generates a new array every time
const columns = data.map((_, i) => createColumn(`col${i}`, `Column ${i}`));

// ✅ Good: Memoize with useMemo
const columns = useMemo(
  () => data.map((_, i) => createColumn(`col${i}`, `Column ${i}`)),
  [data]
);

2. Utilizing Hidden Columns

You can keep data for internal management in the table while hiding it from the display.

const table = useReactTable({
  // ...
  state: {
    columnVisibility: {
      id: false,        // ID (Hidden)
      sortOrder: false, // Sort Order (Hidden)
    },
  },
});

3. Number Formatting

Apply number formatting according to the locale.

const formatNumber = (value: number) => {
  return value.toLocaleString("ja-JP", {
    minimumFractionDigits: 0,
    maximumFractionDigits: 2,
  });
};

// Use in cell definition
columnHelper.accessor("amount", {
  cell: (info) => formatNumber(info.getValue() as number),
  header: "Amount",
});

4. Type-Safe Meta Definition

In TanStack Table v8, the meta type can be extended.

// types.d.ts
import "@tanstack/react-table";

declare module "@tanstack/react-table" {
  interface ColumnMeta<TData extends RowData, TValue> {
    isNumeric?: boolean;
    align?: "left" | "center" | "right";
  }
}

5. Proper Handling of Empty Data

This is how to handle cases where data is missing between groups.

// Set default values during data conversion
allGroups.forEach((group, groupIndex) => {
  group.forEach((item) => {
    // ...
    aggregated[item.id][`count${groupIndex}`] = item.count ?? 0;
    aggregated[item.id][`amount${groupIndex}`] = item.amount ?? 0;
  });
});

Summary

Here is a summary of the key points for dynamic column generation using TanStack Table v8.

Item Implementation Method
Dynamic Type Definition Index Signature [key: string]: T
Column Generation useMemo + createColumnHelper
Group Headers columnHelper.group()
Hidden Columns Controlled by columnVisibility

TanStack Table is a highly flexible and powerful library that can handle complex requirements like dynamic column generation. Its official documentation is extensive, so be sure to check it for further details.

Alternative Approach

In this article, we introduced a pattern for handling "group arrays that increase or decrease dynamically," but another approach is to have group information within the data itself and transform it using Object.groupBy.

The following article explains a data-driven method for column generation:

Choose the approach that best fits your specific use case.

Discussion