見出し画像

【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 #コンポーネント #テーブル

いいなと思ったら応援しよう!

ライト もしよろしければ応援をお願いいたします。 いただいたチップでコーヒーを飲んでがんばります!