Data Table
shadcn/ui の Table primitive + @tanstack/react-table。カイクラの会話履歴一覧画面のように、並び替え / ページングを要する一覧表示で利用する。
プレビュー(動作デモ)
会話履歴を題材にした実装例。ヘッダー列クリックで並び替え、5 件単位でページング。
| 2026-05-25 10:32 | 山田 太郎 | 通話 | 佐藤 健 | 5:12 | 契約更新の問い合わせ。来週折返し希望。 | |
| 2026-05-25 09:48 | 鈴木 花子 | SMS | 高橋 美咲 | — | 予約確認のリマインダーを送信。 | |
| 2026-05-24 18:11 | 田中 翔太 | メール | 佐藤 健 | — | 請求書再発行の依頼。対応済み。 | |
| 2026-05-24 15:22 | 伊藤 次郎 | 通話 | 渡辺 翔太 | 10:12 | 解約相談。サービス継続を提案。 | |
| 2026-05-24 11:05 | 中村 美咲 | LINE | 高橋 美咲 | — | LINE 経由でクーポンに関する質問。 | |
| 2026-05-23 16:40 | 小林 健 | 通話 | 佐藤 健 | 2:25 | 新規プラン申込。設定方法を案内。 | |
| 2026-05-23 14:28 | 加藤 太郎 | メール | 山本 花子 | — | 領収書 PDF を再送。 | |
| 2026-05-23 10:12 | 吉田 花子 | SMS | 渡辺 翔太 | — | 本人確認用ワンタイムコード送信。 | |
| 2026-05-22 17:55 | 山田 太郎 | 通話 | 高橋 美咲 | 1:27 | 支払方法変更について問合せ。 | |
| 2026-05-22 13:20 | 佐々木 翔太 | 通話 | 佐藤 健 | 7:01 | クレーム対応。上長エスカレーション済み。 |
実装例
"use client";
import { useState } from "react";
import {
type ColumnDef,
type RowSelectionState,
type SortingState,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table, TableBody, TableCell,
TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/Button";
import { Checkbox } from "@/components/Checkbox";
import { Icon } from "@/components/Icon";
type InteractionType = "call" | "sms" | "mail" | "line";
type Interaction = {
id: string;
customer: string;
type: InteractionType;
occurredAt: string;
assignee: string;
durationSec: number;
summary: string;
};
const TYPE_LABEL: Record<InteractionType, string> = {
call: "通話", sms: "SMS", mail: "メール", line: "LINE",
};
const TYPE_ICON: Record<InteractionType, string> = {
call: "phone", sms: "message", mail: "mail", line: "message",
};
function formatDuration(sec: number) {
if (sec <= 0) return "—";
return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`;
}
const DATA: Interaction[] = [
{ id: "1", customer: "山田 太郎", type: "call",
occurredAt: "2026-05-25 10:32", assignee: "佐藤 健",
durationSec: 312, summary: "契約更新の問い合わせ。来週折返し希望。" },
{ id: "2", customer: "鈴木 花子", type: "sms",
occurredAt: "2026-05-25 09:48", assignee: "高橋 美咲",
durationSec: 0, summary: "予約確認のリマインダーを送信。" },
// ...
];
const columns: ColumnDef<Interaction>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onChange={(e) => table.toggleAllPageRowsSelected(e.target.checked)}
aria-label="全行を選択"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onChange={(e) => row.toggleSelected(e.target.checked)}
aria-label="行を選択"
/>
),
enableSorting: false,
},
{
accessorKey: "occurredAt",
header: "発生日時",
cell: ({ row }) => (
<span className="whitespace-nowrap">{row.getValue<string>("occurredAt")}</span>
),
},
{
accessorKey: "customer",
header: "顧客",
cell: ({ row }) => (
<span className="font-medium">{row.getValue<string>("customer")}</span>
),
},
{
accessorKey: "type",
header: "種別",
cell: ({ row }) => {
const type = row.getValue<InteractionType>("type");
return (
<span className="inline-flex items-center gap-1">
<Icon name={TYPE_ICON[type]} size={14} />
{TYPE_LABEL[type]}
</span>
);
},
},
{ accessorKey: "assignee", header: "担当者" },
{
accessorKey: "durationSec",
header: "通話時間",
cell: ({ row }) => (
<span className="tabular-nums">
{formatDuration(row.getValue<number>("durationSec"))}
</span>
),
},
{
accessorKey: "summary",
header: "要約",
},
];
export function DataTableExample() {
const [sorting, setSorting] = useState<SortingState>([
{ id: "occurredAt", desc: true },
]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const table = useReactTable({
data: DATA,
columns,
state: { sorting, rowSelection },
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
enableRowSelection: true,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 5 } },
});
return (
<div className="flex flex-col gap-4">
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => {
const canSort = h.column.getCanSort();
const sorted = h.column.getIsSorted();
return (
<TableHead key={h.id}>
{canSort ? (
<button
type="button"
onClick={h.column.getToggleSortingHandler()}
className="inline-flex items-center gap-1 cursor-pointer"
>
{flexRender(h.column.columnDef.header, h.getContext())}
{sorted === "asc" && <Icon name="caret-up" size={10} />}
{sorted === "desc" && <Icon name="caret-down" size={10} />}
</button>
) : (
flexRender(h.column.columnDef.header, h.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>
</Table>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">
{DATA.length} 件中{" "}
{table.getState().pagination.pageIndex * 5 + 1}
{" – "}
{Math.min((table.getState().pagination.pageIndex + 1) * 5, DATA.length)}
{" "}件を表示
</span>
<div className="flex gap-2">
<Button variant="secondary" size="s"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}>前へ</Button>
<Button variant="secondary" size="s"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}>次へ</Button>
</div>
</div>
</div>
);
}仕様
- primitive:
src/components/ui/table.tsx(shadcn 公式そのまま) - 行列ロジック:
@tanstack/react-table(sorting / filtering / pagination / row selection を素朴な hook API で提供) - カラム定義(
ColumnDef)でaccessorKey/header/cellを宣言。cellに独自 React を返してアイコン付き表示などをカスタム可能 - 使用箇所例: 会話履歴一覧、顧客一覧、設定の項目一覧 など
利用基準
- 行数が多く、ソートや検索が必要な一覧 → Data Table
- 5 行以下の単純な並び → 通常の
<table>か Definition List で十分 - 編集が中心の表 → 専用のスプレッドシート系コンポーネントを別途検討