はじめに
前回は FastAPI でユーザーの CRUD 操作を実装しました。
今回はその中からユーザー取得機能を使って、React でユーザー管理画面を作成し、バックエンド API と連携させてみます。
実際の管理画面でよく使われるテーブル機能(ソート、フィルタリング、ページネーション、行選択など)を、モダンな UI ライブラリを使って効率的に実装していきます。
概要
今回は shadcn/ui の DataTable を参考に、バックエンド API と連携したユーザー管理テーブルを作成していきます。
また、前回実装したサイドバーにユーザー管理へのナビゲーションを追加し、管理画面として統合します。
この記事ではユーザー取得機能を中心としたテーブル表示機能の実装が主題です。
ユーザーの追加・編集・削除機能については、UI のモックアップのみの実装となります。
実装手順
1. 必要なコンポーネントをインストール
npx shadcn@latest add table
npx shadcn@latest add checkbox
npx shadcn@latest add select
npx shadcn@latest add dropdown-menu
2. 依存関係をインストール
npm install @tanstack/react-table lucide-react
3. 型定義の作成
まず、API レスポンス用の型定義を作成します。
src/types/api.ts
に以下の型定義を追加:
export type User = {
id: number;
name: string;
email: string;
};
4. ユーザーテーブル用のカスタムフック作成
src/hooks/use-user-table.ts
に以下のフックを追加:
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState,
} from "@tanstack/react-table";
import { useState } from "react";
import type { User } from "../types/api";
export function useUserTable(data: User[], columns: ColumnDef<User>[]) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return { table };
}
5. ユーザーテーブルコンポーネントの作成
src/components/user/user-table.tsx
に以下のコンポーネントを追加:
import { flexRender } from "@tanstack/react-table";
import type { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, ChevronDown, MoreHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useUserTable } from "@/hooks/use-user-table";
import type { User } from "@/types/api.ts";
// モックアップ用のカラム定義
export const createColumns = (): ColumnDef<User>[] => [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="全選択"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="行選択"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => <div>{row.getValue("id")}</div>,
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => <div>{row.getValue("name")}</div>,
},
{
accessorKey: "email",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Email
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => <div className="lowercase">{row.getValue("email")}</div>,
},
{
id: "actions",
enableHiding: false,
cell: ({ row }) => {
const user = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">メニューを開く</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(user.id.toString())}
>
ユーザーIDをコピー
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => console.log("編集:", user)}>
ユーザー情報を編集
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => console.log("削除:", user)}
className="text-red-600 focus:text-red-600"
>
ユーザーを削除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
interface UserTableProps {
data: User[];
isLoading?: boolean;
}
export function UserTable({ data, isLoading = false }: UserTableProps) {
const columns = createColumns();
const { table } = useUserTable(data, columns);
// 選択された行の数を取得
const selectedRowCount = table.getFilteredSelectedRowModel().rows.length;
if (isLoading) {
return (
<div className="w-full">
<div className="flex items-center py-4">
<div className="h-10 w-64 animate-pulse rounded-md bg-muted"></div>
<div className="ml-auto h-10 w-32 animate-pulse rounded-md bg-muted"></div>
</div>
<div className="overflow-hidden rounded-md border">
<div className="h-96 animate-pulse bg-muted"></div>
</div>
</div>
);
}
return (
<div className="w-full">
<div className="flex items-center py-4">
<Input
placeholder="Filter emails..."
value={(table.getColumn("email")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("email")?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
{selectedRowCount > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => console.log("一括削除:", selectedRowCount)}
className="ml-2"
>
選択した{selectedRowCount}件を削除
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto">
Columns <ChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value: boolean) =>
column.toggleVisibility(!!value)
}
>
{column.id === "id" && "ID"}
{column.id === "name" && "Name"}
{column.id === "email" && "Email"}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="overflow-hidden rounded-md border min-w-[800px]">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="text-muted-foreground flex-1 text-sm">
{table.getFilteredSelectedRowModel().rows.length} /{" "}
{table.getFilteredRowModel().rows.length} rows selected.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
);
}
6. API サービスクラスの作成
src/services/user-service.ts
に以下のサービスクラスを追加:
import type { User } from "@/types/api.ts";
const API_BASE_URL = "/api";
// APIレスポンス用の型(実際のAPIレスポンスに合わせる)
type ApiUser = {
id: number;
name: string;
email: string;
};
export class UserService {
/**
* 全ユーザーを取得
*/
static async getAllUsers(): Promise<User[]> {
try {
console.log("API呼び出し開始:", `${API_BASE_URL}/users/`);
const response = await fetch(`${API_BASE_URL}/users/`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
credentials: "include", // クッキーを含める
});
console.log("APIレスポンス:", response.status, response.statusText);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiUser[] = await response.json();
console.log("APIレスポンスデータ:", data);
// APIレスポンスをUser型に変換(不足フィールドをデフォルト値で補完)
const users: User[] = data.map((user) => ({
...user,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
deleted_at: null,
}));
console.log("変換後のユーザーデータ:", users);
return users;
} catch (error) {
console.error("ユーザー一覧の取得に失敗:", error);
throw error;
}
}
}
7. ユーザー管理ページの作成
src/components/users.tsx
に以下のページコンポーネントを追加:
import { useState, useEffect } from "react";
import { UserTable } from "@/components/user/user-table";
import type { User } from "@/types/api.ts";
import { UserService } from "@/services/user-service";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/app-sidebar";
export function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchUsers = async () => {
try {
setIsLoading(true);
setError(null);
const data = await UserService.getAllUsers();
setUsers(data);
} catch (err) {
console.error("ユーザー情報の取得に失敗:", err);
setError("ユーザー情報の読み込みに失敗しました。");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
const handleAddUser = () => {
console.log("新規ユーザー作成");
};
const renderContent = () => {
if (error) {
return (
<div className="space-y-4">
<div className="p-4 border border-red-200 bg-red-50 rounded-md">
<h3 className="text-red-800 font-medium">
ユーザー情報の読み込みに失敗しました
</h3>
<p className="text-red-600 text-sm mt-1">
データの取得中にエラーが発生しました。APIサーバーが起動しているか確認してください。
</p>
</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto w-full">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold tracking-tight text-left">
ユーザー管理
</h1>
<p className="text-muted-foreground">
登録ユーザーの管理と詳細情報を確認できます。
</p>
</div>
<div className="flex items-center space-x-1">
<Button onClick={handleAddUser} size="sm">
<Plus className="h-4 w-4" />
新規ユーザー
</Button>
</div>
</div>
<UserTable data={users} isLoading={isLoading} />
</div>
);
};
return (
<SidebarProvider>
<div className="flex h-screen bg-background">
<AppSidebar />
<div className="flex-1 flex flex-col">
<header className="border-b p-4">
<div className="flex items-center gap-2">
<SidebarTrigger />
<h1 className="text-lg font-semibold">ユーザー管理</h1>
</div>
</header>
<main className="flex-1 p-6 overflow-auto">{renderContent()}</main>
</div>
</div>
</SidebarProvider>
);
}
8. ルーティングの設定
src/routes/index.tsx
にユーザー管理ページのルートを追加:
import { SignInForm } from "@/components/signin-form";
import { SignUpForm } from "@/components/signup-form";
import { Dashboard } from "@/components/dashboard";
import { UsersPage } from "@/components/users";
import { guestRoute, protectedRoute } from "@/utils/auth-loader";
export const createGuestRoutes = () => [
guestRoute("/", <SignInForm />),
guestRoute("/signin", <SignInForm />),
guestRoute("/signup", <SignUpForm />),
];
export const createProtectedRoutes = () => [
protectedRoute("/dashboard", <Dashboard />),
protectedRoute("/users", <UsersPage />),
];
9. サイドバーメニューの更新
src/components/app-sidebar.tsx
にユーザー管理メニューを追加:
import { Home, LogOut, Users } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { Link } from "react-router-dom";
// ... 他のインポート
// Menu items.
const items = [
{
title: "Home",
url: "/dashboard",
icon: Home,
},
{
title: "Users",
url: "/users",
icon: Users,
},
// ... 他のメニューアイテム
];
動作確認
実装が完了したら、以下の手順で動作を確認してみましょう。
1. バックエンド API サーバーを起動
# FastAPI サーバーを起動
docker compose up -d --build
2. React 開発サーバーを起動
# React アプリケーションを起動
npm run dev
3. 完成したユーザー管理画面
ブラウザで http://localhost:5173/users
にアクセスすると、上記のようなユーザー管理画面が表示されます。
実装された機能の確認
- ✅ ユーザー一覧の表示
- ✅ ソート機能(名前・メールアドレス)
- ✅ フィルタリング機能(メールアドレス検索)
- ✅ 行選択機能
- ✅ 列の表示/非表示切り替え
まとめ
今回は shadcn/ui の DataTable コンポーネントを使用して、FastAPI のユーザー取得 API と連携したユーザー管理テーブルを実装しました。
ソート、フィルタリング、ページネーション、行選択などの高機能なテーブル機能を、モダンな UI ライブラリを使って効率的に構築できました。
次回は、今回作成したテーブルの操作メニュー(編集・削除)を実際に動作させ、完全な CRUD 操作を React 側でも実装していきます。