TanStack Table v8 でテーブルのカラムを動的に生成する方法

に公開

TanStack Table とは

TanStack Table は、React、Vue、Solid、Svelte などのフレームワークで使用できるヘッドレスなテーブルライブラリです。

主な特徴:

  • 🎨 ヘッドレス: UIに依存しないため、任意のCSSフレームワークやコンポーネントライブラリと組み合わせ可能
  • 📦 軽量: 必要な機能だけをインポートできるTree-shaking対応
  • 🔧 型安全: TypeScriptで書かれており、優れた型推論を提供
  • 🚀 高パフォーマンス: 仮想化やメモ化による最適化

💡 shadcn/ui の Data Table も内部で TanStack Table を使用しています。

はじめに (本記事で扱う内容)

業務システムでは、データの件数や種類が動的に変わるテーブルを実装することがよくあります。
本記事では、TanStack Table (React Table) v8 を使用して、以下のような要件を満たすテーブルを実装する方法を解説します。

  • 動的なカラム生成: データグループの数に応じてカラム数が変化
  • グループヘッダー: 関連するカラムをグループ化して表示
  • TypeScript対応: 型安全な実装

環境・前提条件

使用技術

技術 バージョン 用途
React 18.x UIライブラリ
TypeScript 5.x 型システム
@tanstack/react-table 8.x テーブルライブラリ

インストール

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

実装のポイント

1. 動的な型定義

TanStack Table では、データの型を厳密に定義することが推奨されています。
しかし、動的にカラムが増減する場合は、インデックスシグネチャを使用します。

// 動的なキーを持つデータ型
type DynamicRowData = {
  [key: string]: string | number | undefined;
};

// createColumnHelperで型を指定
const columnHelper = createColumnHelper<DynamicRowData>();

2. カラムヘルパー関数の作成

カラム定義を簡潔に書くためのヘルパー関数を用意します。

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

meta プロパティの活用

TanStack Table の meta は任意のカスタムデータを格納できます。
ここでは isNumeric フラグを設定し、レンダリング時に右寄せなどのスタイル適用に利用できます。

3. 動的カラム生成(useMemo)

カラム定義は useMemo でメモ化し、依存データが変わったときのみ再計算します。

type Props = {
  mainData: RowData[];
  subGroups: RowData[][];  // 動的に増減するデータグループ
};

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

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

  // 1️⃣ 固定カラム
  cols.push(
    columnHelper.group({
      id: "info",
      header: "",
      columns: [
        createColumn("name", "名前"),
        createColumn("code", "コード"),
      ],
    }),
  );

  // 2️⃣ メインデータのカラム
  cols.push(
    columnHelper.group({
      id: "total",
      header: "合計",
      columns: [
        createColumn("count0", "件数", true),
        createColumn("amount0", "金額", true),
      ],
    }),
  );

  // 3️⃣ サブグループカラム(動的生成)
  for (let i = 0; i < subGroups.length; i++) {
    cols.push(
      columnHelper.group({
        id: `group${i + 1}`,
        header: `グループ${i + 1}`,
        columns: [
          createColumn(`count${i + 1}`, "件数", true),
          createColumn(`amount${i + 1}`, "金額", true),
        ],
      }),
    );
  }

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

4. グループヘッダーの実装

columnHelper.group() を使用すると、複数のカラムをグループ化できます。

columnHelper.group({
  id: "group1",        // ユニークなID
  header: "グループ1", // グループヘッダーのラベル
  columns: [           // グループに含まれるカラム
    createColumn("count1", "件数", true),
    createColumn("amount1", "金額", true),
  ],
});

レンダリング結果のイメージ:

┌──────────┬──────────┬────────────────┬────────────────┬────────────────┐
│          │          │      合計      │   グループ1    │   グループ2    │
├──────────┼──────────┼────────┬───────┼────────┬───────┼────────┬───────┤
│   名前   │  コード  │  件数  │ 金額  │  件数  │ 金額  │  件数  │ 金額  │
├──────────┼──────────┼────────┼───────┼────────┼───────┼────────┼───────┤
│   ...    │   ...    │  ...   │  ...  │  ...   │  ...  │  ...   │  ...  │

5. データの変換処理

元データをテーブル表示用のフラットなデータに変換します。

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

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

  // メインデータとサブグループを結合
  const allGroups: RowData[][] = [mainData, ...subGroups];

  // IDをキーにデータを集約
  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]);

6. テーブルのレンダリング

useReactTable フックでテーブルインスタンスを作成し、flexRender でセルをレンダリングします。

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

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

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

完全なサンプルコード

動的カラムテーブルコンポーネント(クリックで展開)
import { useMemo } from "react";
import {
  ColumnDef,
  createColumnHelper,
  useReactTable,
  getCoreRowModel,
  flexRender,
} from "@tanstack/react-table";

// 動的なキーを持つデータ型
type DynamicRowData = {
  [key: string]: string | number | undefined;
};

// 元データの型
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) => {
  // カラム生成ヘルパー
  const createColumn = (id: string, header: string, isNumeric = false) => {
    return columnHelper.accessor(id, {
      cell: (info) => info.getValue(),
      header: header,
      meta: isNumeric ? { isNumeric: true } : undefined,
    });
  };

  // 動的カラム定義
  const columns = useMemo(() => {
    if (!mainData?.length) return [];

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

    // 固定カラム
    cols.push(
      columnHelper.group({
        id: "info",
        header: "",
        columns: [
          createColumn("name", "名前"),
          createColumn("code", "コード"),
        ],
      }),
    );

    // 合計カラム
    cols.push(
      columnHelper.group({
        id: "total",
        header: "合計",
        columns: [
          createColumn("count0", "件数", true),
          createColumn("amount0", "金額", true),
        ],
      }),
    );

    // サブグループカラム(動的)
    subGroups.forEach((_, i) => {
      cols.push(
        columnHelper.group({
          id: `group${i + 1}`,
          header: `グループ${i + 1}`,
          columns: [
            createColumn(`count${i + 1}`, "件数", true),
            createColumn(`amount${i + 1}`, "金額", true),
          ],
        }),
      );
    });

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

  // データ変換
  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]);

  // テーブルインスタンス
  const table = useReactTable({
    columns,
    data,
    getCoreRowModel: getCoreRowModel(),
  });

  if (data.length === 0) {
    return <p>データがありません</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 & ベストプラクティス

1. パフォーマンス最適化

// ❌ Bad: 毎回新しい配列を生成
const columns = data.map((_, i) => createColumn(`col${i}`, `列${i}`));

// ✅ Good: useMemoでメモ化
const columns = useMemo(
  () => data.map((_, i) => createColumn(`col${i}`, `列${i}`)),
  [data]
);

2. 非表示カラムの活用

内部管理用のデータはテーブルに持たせつつ、表示は隠すことができます。

const table = useReactTable({
  // ...
  state: {
    columnVisibility: {
      id: false,        // ID(非表示)
      sortOrder: false, // ソート順(非表示)
    },
  },
});

3. 数値フォーマット

ロケールに応じた数値フォーマットを適用します。

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

// セル定義で使用
columnHelper.accessor("amount", {
  cell: (info) => formatNumber(info.getValue() as number),
  header: "金額",
});

4. 型安全なmetaの定義

TanStack Table v8 では meta の型を拡張できます。

// 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. 空データの適切なハンドリング

グループ間でデータが欠損している場合の対処法です。

// データ変換時にデフォルト値を設定
allGroups.forEach((group, groupIndex) => {
  group.forEach((item) => {
    // ...
    aggregated[item.id][`count${groupIndex}`] = item.count ?? 0;
    aggregated[item.id][`amount${groupIndex}`] = item.amount ?? 0;
  });
});

まとめ

TanStack Table v8 を使用した動的カラム生成のポイントをまとめます。

項目 実装方法
動的型定義 インデックスシグネチャ [key: string]: T
カラム生成 useMemo + createColumnHelper
グループヘッダー columnHelper.group()
非表示カラム columnVisibility で制御

TanStack Table は柔軟性が高く、動的なカラム生成のような複雑な要件にも対応できる強力なライブラリです。
公式ドキュメントも充実しているので、ぜひ参考にしてください。

別のアプローチ

本記事では「動的に増減するグループ配列」に対応するパターンを紹介しましたが、データ自体にグループ情報を持たせて Object.groupBy で変換するアプローチもあります。

以下の記事では、データドリブンなカラム生成方法が解説されています:

ユースケースに応じて、適切なアプローチを選択してください。

参考リンク

Discussion