Next.js + shadcn/ui + TanStackTableでモダンなテーブルを作ろう
テーブルの実装には、react-data-table-componentや、MUIのDataGrid、MantineUIのMantineDataTableなどが用いられてきました。
今回はテーブルのHeadressUIライブラリであるTanstackTableと、RadixUIとTailwindCSSで実装したコンポーネント群であるshadcn/uiを使って、簡単なテーブルを作ります。
shadcn/uiについて
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のインストール方法は以下を参考にしてください。
TanStackTableについて
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のドキュメントに記述があるので、そちらをご覧ください。
この記事でやること
本来UI要素において、複数のライブラリを組み合わせることはあまりないのですが、TanStackTableはロジックを提供しているため、逆にUIを提供しているshadcn/uiと組み合わせることが可能です。
また、今回のコードは以下のGitHubにアップロードしています。適宜確認してください。
一応デプロイしました。見た目の確認をしたい場合はご確認ください。あくまでこれはデフォルトのデザインなので、お好きなスタイルを当てることが可能です。
TanStackTableの使い方
以下のコマンドでインストールできます。
npmでもyarnでもpnpmでもbunでもいいです。
npm install @tanstack/react-table
次に、基本的なテーブルコンポーネントの全体をぱっと貼ります。
"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を使うと簡単に列の情報を定義できます。
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型であれば配列で定義することができます。
- useReactTableでtableを生成します。
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
- あとはよしなに展開すればテーブルを構築することができます。
コードを見ていただいてわかると思いますが、SampleTableは<table>
や<tbody>
など通常のタグを使っています。つまり、ここをshadcn/uiのTableコンポーネントで置き換えればいいんじゃ!!
shadcn/uiとそのTableコンポーネントの使い方
実際に組み合わせる前に、shadcn/uiをおさらいします。
インストール方法はこちらを参照してください。
Tableコンポーネントは、以下のようにTableBodyやTableCellなどのパーツを組み合わせて構築します。
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つの例を組み合わせれば完成!
"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