🐸

Next.js + shadcn/ui + TanStackTableでモダンなテーブルを作ろう

2024/01/17に公開

テーブルの実装には、react-data-table-componentや、MUIのDataGrid、MantineUIのMantineDataTableなどが用いられてきました。

今回はテーブルのHeadressUIライブラリであるTanstackTableと、RadixUIとTailwindCSSで実装したコンポーネント群であるshadcn/uiを使って、簡単なテーブルを作ります。

shadcn/uiについて

https://ui.shadcn.com/

Beautifully designed components that you can copy and paste into your apps. Accessible. Customizable. Open Source.
あなたのアプリにコピー&ペーストできる、美しくデザインされたコンポーネント。アクセスしやすい。カスタマイズ可能。オープンソース。(機械翻訳)

shadcn/uiはRadixUIとTailwindCSSで実装したコンポーネント群です。これはコンポーネントライブラリではなく、コピペできる再利用可能なコンポーネント集みたいなものです。
なのでnpmでコンポーネントを取得することはできず、CLI経由でコードを取得して、それを個人の好きなようにカスタマイズすることができます。

なぜパッケージとしないかは、公式が以下のように回答しています。

なぜ依存関係としてパッケージ化されずにコピー/ペーストするのでしょうか?
この背後にある考え方は、コードの所有権と制御をユーザーに与え、コンポーネントの構築方法とスタイルを決定できるようにすることです。
いくつかの賢明なデフォルトから始めて、ニーズに合わせてコンポーネントをカスタマイズします。
コンポーネントを npm パッケージにパッケージ化する場合の欠点の 1 つは、スタイルが実装と結びついてしまうことです。コンポーネントの設計は、その実装とは別にする必要があります。

近年のヘッドレスUIやCSSinJSの奔流に沿って、実装とスタイルを分けるような考えになっているようです。

shadcn/uiのインストール方法は以下を参考にしてください。
https://ui.shadcn.com/docs/installation

TanStackTableについて

https://tanstack.com/table/v8

TanStack Table is a Headless UI library for building powerful tables & datagrids for TS/JS, React, Vue, Solid, and Svelte.
TanStack Table は、TS/JS、React、Vue、Solid、Svelte 用の強力なテーブルとデータグリッドを構築するためのヘッドレス UIライブラリです。(機械翻訳)

要はコンポーネントライブラリのように見た目を提供するのではなく、テーブルに必要なデータロジックなどを提供してくれるライブラリということですね。

ヘッドレスUIについては、TanStackTableのドキュメントに記述があるので、そちらをご覧ください。

https://tanstack.com/table/v8/docs/guide/introduction#what-is-headless-ui

この記事でやること

本来UI要素において、複数のライブラリを組み合わせることはあまりないのですが、TanStackTableはロジックを提供しているため、逆にUIを提供しているshadcn/uiと組み合わせることが可能です。

また、今回のコードは以下のGitHubにアップロードしています。適宜確認してください。

https://github.com/imaimai17468/table-sample

一応デプロイしました。見た目の確認をしたい場合はご確認ください。あくまでこれはデフォルトのデザインなので、お好きなスタイルを当てることが可能です。

https://table-sample-seven.vercel.app/

TanStackTableの使い方

以下のコマンドでインストールできます。
npmでもyarnでもpnpmでもbunでもいいです。

npm install @tanstack/react-table

次に、基本的なテーブルコンポーネントの全体をぱっと貼ります。

TanStackTable.tsx
"use client";

import { useState } from "react";

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


type Person = {
  firstName: string;
  lastName: string;
  age: number;
  visits: number;
  status: string;
  progress: number;
};

const defaultData: Person[] = [
  {
    firstName: "tanner",
    lastName: "linsley",
    age: 24,
    visits: 100,
    status: "In Relationship",
    progress: 50,
  },
  {
    firstName: "tandy",
    lastName: "miller",
    age: 40,
    visits: 40,
    status: "Single",
    progress: 80,
  },
  {
    firstName: "joe",
    lastName: "dirte",
    age: 45,
    visits: 20,
    status: "Complicated",
    progress: 10,
  },
];

const columnHelper = createColumnHelper<Person>();

const columns = [
  columnHelper.accessor("firstName", {
    cell: (info) => info.getValue(),
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor((row) => row.lastName, {
    id: "lastName",
    cell: (info) => <i>{info.getValue()}</i>,
    header: () => <span>Last Name</span>,
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor("age", {
    header: () => "Age",
    cell: (info) => info.renderValue(),
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor("visits", {
    header: () => <span>Visits</span>,
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor("status", {
    header: "Status",
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor("progress", {
    header: "Profile Progress",
    footer: (info) => info.column.id,
  }),
];

export const TanStackTable = () => {
  const [data, setData] = useState<Person[]>(defaultData);

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

  return (
    <table>
      <thead>
        {table.getHeaderGroups().map((headerGroup) => (
          <tr key={headerGroup.id}>
            {headerGroup.headers.map((header) => (
              <th key={header.id}>
                {header.isPlaceholder
                  ? null
                  : 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>
      <tfoot>
        {table.getFooterGroups().map((footerGroup) => (
          <tr key={footerGroup.id}>
            {footerGroup.headers.map((header) => (
              <th key={header.id}>
                {header.isPlaceholder
                  ? null
                  : flexRender(
                      header.column.columnDef.footer,
                      header.getContext()
                    )}
              </th>
            ))}
          </tr>
        ))}
      </tfoot>
    </table>
  );
}

部分的に解説します。

  • これはAppRouterじゃなければ必要ありません。
"use client";
  • columnHelperを使うと簡単に列の情報を定義できます。

https://tanstack.com/table/v8/docs/guide/column-defs#column-helpers

const columnHelper = createColumnHelper<Person>();

const columns = [
  columnHelper.accessor("firstName", {
    cell: (info) => info.getValue(),
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor((row) => row.lastName, {
    id: "lastName",
    cell: (info) => <i>{info.getValue()}</i>,
    header: () => <span>Last Name</span>,
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor("age", {
    header: () => "Age",
    cell: (info) => info.renderValue(),
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor("visits", {
    header: () => <span>Visits</span>,
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor("status", {
    header: "Status",
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor("progress", {
    header: "Profile Progress",
    footer: (info) => info.column.id,
  }),
];

また、columnHelperを使わなくても、ColumnDef型であれば配列で定義することができます。

https://tanstack.com/table/v8/docs/guide/migrating

https://tanstack.com/table/v8/docs/api/core/column-def

  • useReactTableでtableを生成します。
const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
});
  • あとはよしなに展開すればテーブルを構築することができます。

https://tanstack.com/table/v8/docs/api/core/table

https://tanstack.com/table/v8/docs/api/core/row

コードを見ていただいてわかると思いますが、SampleTableは<table><tbody>など通常のタグを使っています。つまり、ここをshadcn/uiのTableコンポーネントで置き換えればいいんじゃ!!

shadcn/uiとそのTableコンポーネントの使い方

実際に組み合わせる前に、shadcn/uiをおさらいします。
インストール方法はこちらを参照してください。

https://ui.shadcn.com/docs/installation/next

Tableコンポーネントは、以下のようにTableBodyやTableCellなどのパーツを組み合わせて構築します。

https://ui.shadcn.com/docs/components/table

ShadcnTable.tsx
import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const invoices = [
  {
    invoice: "INV001",
    paymentStatus: "Paid",
    totalAmount: "$250.00",
    paymentMethod: "Credit Card",
  },
  {
    invoice: "INV002",
    paymentStatus: "Pending",
    totalAmount: "$150.00",
    paymentMethod: "PayPal",
  },
  {
    invoice: "INV003",
    paymentStatus: "Unpaid",
    totalAmount: "$350.00",
    paymentMethod: "Bank Transfer",
  },
  {
    invoice: "INV004",
    paymentStatus: "Paid",
    totalAmount: "$450.00",
    paymentMethod: "Credit Card",
  },
  {
    invoice: "INV005",
    paymentStatus: "Paid",
    totalAmount: "$550.00",
    paymentMethod: "PayPal",
  },
  {
    invoice: "INV006",
    paymentStatus: "Pending",
    totalAmount: "$200.00",
    paymentMethod: "Bank Transfer",
  },
  {
    invoice: "INV007",
    paymentStatus: "Unpaid",
    totalAmount: "$300.00",
    paymentMethod: "Credit Card",
  },
]

export function ShadcnTable() {
  return (
    <Table>
      <TableCaption>A list of your recent invoices.</TableCaption>
      <TableHeader>
        <TableRow>
          <TableHead className="w-[100px]">Invoice</TableHead>
          <TableHead>Status</TableHead>
          <TableHead>Method</TableHead>
          <TableHead className="text-right">Amount</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {invoices.map((invoice) => (
          <TableRow key={invoice.invoice}>
            <TableCell className="font-medium">{invoice.invoice}</TableCell>
            <TableCell>{invoice.paymentStatus}</TableCell>
            <TableCell>{invoice.paymentMethod}</TableCell>
            <TableCell className="text-right">{invoice.totalAmount}</TableCell>
          </TableRow>
        ))}
      </TableBody>
      <TableFooter>
        <TableRow>
          <TableCell colSpan={3}>Total</TableCell>
          <TableCell className="text-right">$2,500.00</TableCell>
        </TableRow>
      </TableFooter>
    </Table>
  )
}

実際に組み合わせる

あとは2つの例を組み合わせれば完成!

SampleTable.tsx
"use client";

import { useState } from "react";

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

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

type Person = {
  firstName: string;
  lastName: string;
  age: number;
  visits: number;
  status: string;
  progress: number;
};

const defaultData: Person[] = [
  {
    firstName: "tanner",
    lastName: "linsley",
    age: 24,
    visits: 100,
    status: "In Relationship",
    progress: 50,
  },
  {
    firstName: "tandy",
    lastName: "miller",
    age: 40,
    visits: 40,
    status: "Single",
    progress: 80,
  },
  {
    firstName: "joe",
    lastName: "dirte",
    age: 45,
    visits: 20,
    status: "Complicated",
    progress: 10,
  },
];

const columnHelper = createColumnHelper<Person>();

const columns = [
  columnHelper.accessor("firstName", {
    cell: (info) => info.getValue(),
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor((row) => row.lastName, {
    id: "lastName",
    cell: (info) => <i>{info.getValue()}</i>,
    header: () => <span>Last Name</span>,
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor("age", {
    header: () => "Age",
    cell: (info) => info.renderValue(),
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor("visits", {
    header: () => <span>Visits</span>,
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor("status", {
    header: "Status",
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor("progress", {
    header: "Profile Progress",
    footer: (info) => info.column.id,
  }),
];

export const SampleTable = () => {
  const [data, setData] = useState<Person[]>(defaultData);

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

  return (
    <Table>
      <TableHeader>
        {table.getHeaderGroups().map((headerGroup) => (
          <TableRow key={headerGroup.id}>
            {headerGroup.headers.map((header) => (
              <TableHead key={header.id}>
                {header.isPlaceholder
                  ? null
                  : flexRender(
                      header.column.columnDef.header,
                      header.getContext()
                    )}
              </TableHead>
            ))}
          </TableRow>
        ))}
      </TableHeader>
      <TableBody>
        {table.getRowModel().rows.map((row) => (
          <TableRow key={row.id}>
            {row.getVisibleCells().map((cell) => (
              <TableCell key={cell.id}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </TableCell>
            ))}
          </TableRow>
        ))}
      </TableBody>
      <TableFooter>
        {table.getFooterGroups().map((footerGroup) => (
          <TableRow key={footerGroup.id}>
            {footerGroup.headers.map((header) => (
              <TableHead key={header.id}>
                {header.isPlaceholder
                  ? null
                  : flexRender(
                      header.column.columnDef.footer,
                      header.getContext()
                    )}
              </TableHead>
            ))}
          </TableRow>
        ))}
      </TableFooter>
    </Table>
  );
};

終わりに

どんどんUIライブラリやCSSが進化していってるのに従って、柔軟な実装を取りやすくなりました。
今回は単にテーブルコンポーネントを実装しましたが、RadixUIやArkUIなどのヘッドレスUIと、pandaCSSやkumaUIといったZero-runtimeのCSSinJSは相性がいいと思いますので、他にも色々な実装ができると思っています。

また、今回使用したTanStackTableは、他にもデータテーブルとして多くの機能を持っており、ソートやフィルタリングやページングなども簡単に実装することができます。
他にも、TanStackVirtualと組み合わせて大きいデータテーブルを仮想表示に対応させたり、DnD-Kitと組み合わせてドラッグ可能な表を作ることが可能です。(需要があれば記事書きたい)

これからフロントエンド関連は、より早く進化が進んでいくかと思われますが、なんとかしがみつきながらキャッチアップしていきたいですね。一緒に頑張りましょう。

Discussion