メインコンテンツへスキップ
見出し画像

【Next.js】管理画面で使えるデータテーブルコンポーネントを作成

    こんにちは、ライトです。
    前回は、管理画面で状態を表示するための StatusBadge コンポーネントを作成しました。

    <StatusBadge variant="success">有効</StatusBadge>
    <StatusBadge variant="warning">承認待ち</StatusBadge>
    <StatusBadge variant="danger">停止中</StatusBadge>

    ステータスバッジを使うと、ユーザーや商品、注文などの状態を視覚的にわかりやすく表示できます。

    今回は、その StatusBadge を活用しながら、管理画面でよく使う データテーブルコンポーネント を作成します。

    管理画面では、一覧画面が何度も出てきます。

    • ユーザー一覧

    • 顧客一覧

    • 商品一覧

    • 注文一覧

    • 売上一覧

    • ログ一覧

    そのたびに table のHTMLやCSSを個別に書いていると、画面ごとに余白や色、空データ表示の見た目がズレやすくなります。

    そこで今回は、列定義とデータを渡すだけで一覧を表示できる DataTable コンポーネントを作成します。


    今回作成するもの

    今回は、以下のように使える DataTable コンポーネントを作成します。

    <DataTable
      columns={columns}
      data={users}
      getRowKey={(user) => user.id}
      emptyMessage="ユーザーが見つかりません。"
    />

    今回の DataTable では、以下に対応します。

    • columnsで列を定義する

    • dataで表示データを渡す

    • renderでセル表示をカスタマイズする

    • 空データ表示に対応する

    • ローディング表示に対応する

    • 横スクロールに対応する

    • StatusBadgeをテーブル内で使う

    いきなり検索、ページネーション、ソートまで入れると少し大きくなるので、今回はまず基本のテーブルコンポーネントを作ります。

    検索やページネーションは、次の記事以降で追加していく想定です。


    完成形

    以下のようなデータテーブルを作成します。

    画像
    ユーザー一覧を作成したテーブルコンポーネントで表示

    ユーザー一覧を表示し、ステータス列には前回作成した StatusBadge を表示します。


    ファイル構成

    今回は、以下の構成で作成します。

    components/
      ui/
        StatusBadge.tsx  # 前回作成のコンポーネント
        DataTable.tsx
    
    app/
      page.tsx

    DataTable は管理画面以外でも使いやすい汎用UIなので、components/ui/ 配下に作成します。

    前回作成した StatusBadge も同じ components/ui/ に置いている想定です。


    DataTableで実装する機能

    今回の DataTable では、以下の機能を実装します。

    • 列定義をpropsで受け取る

    • 表示データをpropsで受け取る

    • 行のkeyを指定できる

    • セルの表示をrenderで自由に変えられる

    • データが空の場合の表示に対応する

    • ローディング中の表示に対応する

    • 横幅が足りない場合は横スクロールする

    管理画面の一覧では、単純な文字列だけでなく、バッジやボタン、リンクなどを表示したいことがあります。

    そのため、セルの中身は render で自由に指定できるようにします。


    DataTable.tsxを作成する

    それでは、DataTable.tsx を作成します。

    components/ui/DataTable.tsx

    import type { ReactNode } from "react";
    
    export type DataTableColumn<T> = {
      key: string;
      header: string;
      render: (item: T) => ReactNode;
      className?: string;
    };
    
    type DataTableProps<T> = {
      columns: DataTableColumn<T>[];
      data: T[];
      getRowKey: (item: T) => string | number;
      emptyMessage?: string;
      isLoading?: boolean;
    };
    
    export default function DataTable<T>({
      columns,
      data,
      getRowKey,
      emptyMessage = "データがありません。",
      isLoading = false,
    }: DataTableProps<T>) {
      return (
        <div className="overflow-hidden rounded-lg border border-slate-200 bg-white">
          <div className="overflow-x-auto">
            <table className="w-full min-w-[720px] text-left text-sm">
              <thead className="bg-slate-50 text-xs font-medium text-slate-500">
                <tr>
                  {columns.map((column) => (
                    <th
                      key={column.key}
                      className={["px-6 py-3", column.className ?? ""].join(" ")}
                    >
                      {column.header}
                    </th>
                  ))}
                </tr>
              </thead>
    
              <tbody className="divide-y divide-slate-100">
                {isLoading &&
                  Array.from({ length: 5 }).map((_, rowIndex) => (
                    <tr key={rowIndex}>
                      {columns.map((column) => (
                        <td key={column.key} className="px-6 py-4">
                          <div className="h-4 w-24 animate-pulse rounded bg-slate-100" />
                        </td>
                      ))}
                    </tr>
                  ))}
    
                {!isLoading &&
                  data.map((item) => (
                    <tr
                      key={getRowKey(item)}
                      className="transition hover:bg-slate-50/80"
                    >
                      {columns.map((column) => (
                        <td
                          key={column.key}
                          className={["px-6 py-4", column.className ?? ""].join(
                            " "
                          )}
                        >
                          {column.render(item)}
                        </td>
                      ))}
                    </tr>
                  ))}
    
                {!isLoading && data.length === 0 && (
                  <tr>
                    <td
                      colSpan={columns.length}
                      className="px-6 py-12 text-center text-sm text-slate-500"
                    >
                      {emptyMessage}
                    </td>
                  </tr>
                )}
              </tbody>
            </table>
          </div>
        </div>
      );
    }

    これで、基本的なデータテーブルコンポーネントができました。


    columnsで列を定義する

    DataTable では、列の情報を columns として渡します。

    export type DataTableColumn<T> = {
      key: string;         # 列を識別するための値
      header: string;      # テーブルヘッダーに表示する文字
      render: (item: T) => ReactNode;  # セルの中身を表示する関数
      className?: string;  # 列ごとに追加したいクラス
    };

    たとえば、ユーザー一覧の場合は次のように定義できます。

    const columns: DataTableColumn<User>[] = [
      {
        key: "name",
        header: "名前",
        render: (user) => user.name,
      },
      {
        key: "email",
        header: "メールアドレス",
        render: (user) => user.email,
      },
    ];

    このようにしておくと、DataTable 側はデータの中身を知る必要がありません。

    表示方法は、使う側が columns で指定します。


    renderでセル表示をカスタマイズする

    render を使うことで、セルの中身を自由に作れます。

    たとえば、名前の列では、名前だけでなくIDも表示できます。

    {
      key: "name",
      header: "名前",
      render: (user) => (
        <div>
          <div className="font-medium text-slate-900">{user.name}</div>
          <div className="mt-1 text-xs text-slate-400">ID: {user.id}</div>
        </div>
      ),
    }

    メールアドレスは、文字色を少し薄くして表示できます。

    {
      key: "email",
      header: "メールアドレス",
      render: (user) => (
        <span className="text-slate-600">{user.email}</span>
      ),
    }

    このように、render を用意しておくと、単純なテキストだけでなく、装飾付きの表示や別コンポーネントも使えます。


    StatusBadgeをテーブル内で使う

    前回作成した StatusBadge も、render の中で使えます。

    function renderStatus(status: User["status"]) {
      switch (status) {
        case "active":
          return <StatusBadge variant="success">有効</StatusBadge>;
        case "pending":
          return <StatusBadge variant="warning">承認待ち</StatusBadge>;
        case "suspended":
          return <StatusBadge variant="danger">停止中</StatusBadge>;
      }
    }

    列定義では、次のように使います。

    {
      key: "status",
      header: "ステータス",
      render: (user) => renderStatus(user.status),
    }

    これで、ステータス列に色付きのバッジを表示できます。

    管理画面では状態を見分けることが多いため、テーブルとステータスバッジはかなり相性が良いです。


    空データ表示に対応する

    一覧画面では、データが0件の場合もあります。

    そのため、DataTable では data.length === 0 のときに空データ表示を出します。

    {!isLoading && data.length === 0 && (
      <tr>
        <td
          colSpan={columns.length}
          className="px-6 py-12 text-center text-sm text-slate-500"
        >
          {emptyMessage}
        </td>
      </tr>
    )}

    colSpan={columns.length} を指定しているので、すべての列をまたいでメッセージを表示できます。

    使う側では、emptyMessage を指定できます。

    <DataTable
      columns={columns}
      data={users}
      getRowKey={(user) => user.id}
      emptyMessage="ユーザーが見つかりません。"
    />

    データがないときの表示も共通化しておくと、一覧画面ごとのばらつきを減らせます。


    ローディング表示に対応する

    APIからデータを取得する場合、読み込み中の状態も必要になります。

    今回は、isLoading が true のときに簡単なスケルトン表示を出します。

    {isLoading &&
      Array.from({ length: 5 }).map((_, rowIndex) => (
        <tr key={rowIndex}>
          {columns.map((column) => (
            <td key={column.key} className="px-6 py-4">
              <div className="h-4 w-24 animate-pulse rounded bg-slate-100" />
            </td>
          ))}
        </tr>
      ))}

    animate-pulse を使うことで、読み込み中であることがわかりやすくなります。

    <div className="h-4 w-24 animate-pulse rounded bg-slate-100" />

    今回はシンプルに各セルへ同じスケルトンを表示しています。

    必要であれば、列ごとに幅を変えたり、行数をpropsで指定できるようにしてもよいです。


    横スクロールに対応する

    管理画面のテーブルは、列数が増えることがあります。

    モバイルやタブレットでは横幅が足りなくなることもあるため、横スクロールに対応しておきます。

    <div className="overflow-x-auto">
      <table className="w-full min-w-[720px] text-left text-sm">
        ...
      </table>
    </div>

    overflow-x-auto を指定することで、横幅が足りない場合に横スクロールできます。

    また、テーブルには min-w-[720px] を指定しています。

    これにより、狭い画面でも列が潰れすぎないようにしています。


    管理画面レイアウトに配置する

    次に、管理画面レイアウトの中で DataTable を使ってみます。

    今回はユーザー一覧を例にします。

    app/page.tsx

    import DashboardLayout from "@/components/dashboard/DashboardLayout";
    import DataTable, { DataTableColumn } from "@/components/ui/DataTable";
    import StatusBadge from "@/components/ui/StatusBadge";
    
    type User = {
      id: number;
      name: string;
      email: string;
      role: string;
      status: "active" | "pending" | "suspended";
    };
    
    const users: User[] = [
      {
        id: 1,
        name: "山田 太郎",
        email: "yamada@example.com",
        role: "管理者",
        status: "active",
      },
      {
        id: 2,
        name: "佐藤 花子",
        email: "sato@example.com",
        role: "編集者",
        status: "pending",
      },
      {
        id: 3,
        name: "田中 一郎",
        email: "tanaka@example.com",
        role: "閲覧者",
        status: "suspended",
      },
    ];
    
    function renderStatus(status: User["status"]) {
      switch (status) {
        case "active":
          return <StatusBadge variant="success">有効</StatusBadge>;
        case "pending":
          return <StatusBadge variant="warning">承認待ち</StatusBadge>;
        case "suspended":
          return <StatusBadge variant="danger">停止中</StatusBadge>;
      }
    }
    
    const columns: DataTableColumn<User>[] = [
      {
        key: "name",
        header: "名前",
        render: (user) => (
          <div>
            <div className="font-medium text-slate-900">{user.name}</div>
            <div className="mt-1 text-xs text-slate-400">ID: {user.id}</div>
          </div>
        ),
      },
      {
        key: "email",
        header: "メールアドレス",
        render: (user) => (
          <span className="text-slate-600">{user.email}</span>
        ),
      },
      {
        key: "role",
        header: "権限",
        render: (user) => (
          <span className="text-slate-600">{user.role}</span>
        ),
      },
      {
        key: "status",
        header: "ステータス",
        render: (user) => renderStatus(user.status),
      },
    ];
    
    export default function Page() {
      return (
        <DashboardLayout>
          <div className="space-y-6">
            <section>
              <p className="text-sm font-medium text-primary-700">Users</p>
              <h2 className="mt-1 text-2xl font-semibold text-slate-950">
                ユーザー一覧
              </h2>
              <p className="mt-2 max-w-2xl text-sm leading-7 text-slate-600">
                管理画面で使うデータテーブルコンポーネントです。
                ステータス列には、前回作成したStatusBadgeを表示しています。
              </p>
            </section>
    
            <DataTable
              columns={columns}
              data={users}
              getRowKey={(user) => user.id}
              emptyMessage="ユーザーが見つかりません。"
            />
          </div>
        </DashboardLayout>
      );
    }

    これで、管理画面レイアウト内にユーザー一覧テーブルを表示できます。

    画像
    ユーザー一覧テーブル

    ステータス列には、前回作成した StatusBadge が表示されます。


    ローディング状態を確認する

    ローディング状態を確認したい場合は、isLoading を true にします。

    <DataTable
      columns={columns}
      data={[]}
      getRowKey={(user) => user.id}
      isLoading
    />

    この場合、データの代わりにスケルトン表示が出ます。

    画像
    ローディング中に表示されるスケルトン表示

    APIからデータを取得する実装にした場合は、取得中だけ isLoading を true にするとよさそうです。


    空データ状態を確認する

    空データ状態を確認する場合は、data に空配列を渡します。

    <DataTable
      columns={columns}
      data={[]}
      getRowKey={(user) => user.id}
      emptyMessage="ユーザーが見つかりません。"
    />

    データがない場合は、指定したメッセージが表示されます。

    画像
    データが0件の場合のメッセージ表示

    一覧画面では、検索結果が0件のときにも同じ仕組みを使えます。


    実装のポイント

    今回のポイントは、テーブルの見た目と表示ロジックを共通化したことです。

    • DataTable  
      テーブルの枠、ヘッダー、行、空データ、ローディングを担当

    • columns  
      表示する列を定義

    • render  
      セルの中身を自由に作る

    • StatusBadge  
      状態表示を見やすくする

    DataTable 側は、データの中身を知りません。

    どの列を表示するか、どのように表示するかは columns で指定します。

    const columns: DataTableColumn<User>[] = [
      {
        key: "status",
        header: "ステータス",
        render: (user) => renderStatus(user.status),
      },
    ];

    このようにしておくと、ユーザー一覧だけでなく、顧客一覧、商品一覧、注文一覧などにも使い回せます。


    さらに改善できそうな点

    今回の実装では、基本的なデータテーブルに絞りました。

    さらに改善するなら、以下のような機能も追加できそうです。

    • 検索・絞り込みに対応する

    • ページネーションを追加する

    • ソートに対応する

    • 行クリックで詳細画面へ遷移する

    • 編集・削除ボタンを追加する

    • チェックボックス選択に対応する

    • 列ごとの幅指定に対応する

    • ローディング行数をpropsで指定できるようにする

    特に、管理画面では検索とページネーションがよく使われます。

    次は、今回作成した DataTable に組み合わせる形で、検索・絞り込みバーやページネーションを作成すると、より実務に近い一覧画面になりそうです。


    まとめ

    今回は、Next.jsで管理画面に使える DataTable コンポーネントを作成しました。

    今回の実装では、以下に対応しました。

    • columnsで列を定義する

    • dataで表示データを渡す

    • renderでセルの中身を自由に表示する

    • 空データ表示に対応する

    • ローディング表示に対応する

    • 横スクロールに対応する

    • StatusBadgeをテーブル内で使う

    管理画面では、一覧画面が何度も出てきます。

    そのたびにテーブルのHTMLや見た目を個別に作るのではなく、DataTable として共通化しておくと、画面ごとの実装が楽になります。

    また、前回作成した StatusBadge を組み込むことで、状態もわかりやすく表示できます。

    次は、この DataTable に検索やページネーションを組み合わせて、より実用的な一覧画面にしていきたいと思います。

    #NextJS #React #コンポーネント #テーブル

     
     
    主にバックエンドの開発をしています。 AIを業務に使いながら、「もっと早く知りたかった」と思うことが山ほど出てきました。 そのリアルな気づきや失敗も含めて、エンジニアに届けたくてnoteを始めました。 現場目線の発信を目指します。 https://code-light.jp/

    あなたへのおすすめ