0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

shadcn/ui DataTable を使ってユーザー管理画面を実装してみた

Last updated at Posted at 2025-09-21

はじめに

前回は 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. 完成したユーザー管理画面

image.png

ブラウザで http://localhost:5173/users にアクセスすると、上記のようなユーザー管理画面が表示されます。

実装された機能の確認

  • ✅ ユーザー一覧の表示
  • ✅ ソート機能(名前・メールアドレス)
  • ✅ フィルタリング機能(メールアドレス検索)
  • ✅ 行選択機能
  • ✅ 列の表示/非表示切り替え

まとめ

今回は shadcn/ui の DataTable コンポーネントを使用して、FastAPI のユーザー取得 API と連携したユーザー管理テーブルを実装しました。
ソート、フィルタリング、ページネーション、行選択などの高機能なテーブル機能を、モダンな UI ライブラリを使って効率的に構築できました。

次回は、今回作成したテーブルの操作メニュー(編集・削除)を実際に動作させ、完全な CRUD 操作を React 側でも実装していきます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?