見出し画像

【Next.js】管理画面の検索条件とページネーションをURLクエリと連動する

こんにちは、ライトです。
これまで、管理画面で使うコンポーネントとして以下を作成してきました。

  • 管理画面レイアウト

  • StatusBadge

  • DataTable

  • Pagination

  • SearchFilter

一覧画面まわりでは、DataTable、Pagination、SearchFilterBar を作成しました。

ここまでで、

  • データを表示する

  • 検索・絞り込みを行う

  • ページを切り替える

  • 表示件数を変更する

ところまではできています。

ただ、現在の状態管理が useState だけだと、ページを再読み込みしたときに検索条件やページ番号がリセットされてしまいます。

管理画面では、次のようなURLで一覧状態を復元できると便利です。

/users?keyword=yamada&status=active&role=admin&page=2&pageSize=20

今回は、検索条件とページネーションを URLクエリ と連動させて、より実務に近い一覧画面にしていきます。


URLクエリと連動する理由

検索条件やページ番号をURLに持たせると、次のようなメリットがあります。

  1. ページを再読み込みしても状態が残る

  2. URLを共有できる

  3. ブラウザバックで前の検索状態に戻れる

  4. 検索条件をブックマークできる

  5. 管理画面らしい挙動になる

たとえば、ユーザー一覧で「有効な管理者だけを表示して2ページ目を見ている」場合、URLに状態が入っていれば、再読み込みしても同じ状態を表示できます。

/users?status=active&role=admin&page=2

一覧画面では、URLと状態を連動させておくと使いやすくなります。


今回作成するもの

今回は、以下の状態をURLクエリと連動します。

  • keyword  
    キーワード検索

  • status  
    ステータス絞り込み

  • role  
    権限絞り込み

  • page  
    現在ページ

  • pageSize  
    表示件数

URLの例です。

/users?keyword=yamada&status=active&role=admin&page=2&pageSize=10

画面では、これまで作成した以下のコンポーネントを使います。

  • SearchFilterBar

  • DataTable

  • Pagination

  • StatusBadge

  • DashboardLayout


完成形

以下のような一覧画面を作成します。

URLと検索フィールドが一致している

検索条件やページ番号を変更すると、URLクエリも更新されます。


ファイル構成

今回は、以下の構成を想定します。

components/
  ui/
    DataTable.tsx
    Pagination.tsx
    SearchFilterBar.tsx
    StatusBadge.tsx

components/
  dashboard/
    DashboardLayout.tsx
    Header.tsx
    icons.tsx
    MobileMenu.tsx
    navItems.tsx
    NavMenu.tsx
    Sidebar.tsx

app/
  page.tsx

今回は主に以下を扱います。

  • SearchFilterBar.tsx  
    URLクエリから受け取った値を表示できるようにする

  • app/page.tsx  
    useSearchParams / useRouter / usePathname を使ってURLと状態を連動する


SearchFilterBarをURL連動しやすい形にする

以前作成した SearchFilterBar は、内部で検索条件を useState 管理する形でした。

URLクエリと連動する場合は、URLから取得した値を初期値として反映したり、ブラウザバックでURLが変わったときに入力欄も更新したくなります。

そのため、今回は values をpropsで受け取り、内部の入力状態と同期できる形にします。

components/ui/SearchFilterBar.tsx

"use client";

import { FormEvent, useEffect, useState } from "react";

export type SearchFilterValues = {
  keyword: string;
  status: string;
  role: string;
};

type SearchFilterBarProps = {
  values: SearchFilterValues;
  onSearch: (values: SearchFilterValues) => void;
  onReset?: () => void;
};

const initialValues: SearchFilterValues = {
  keyword: "",
  status: "all",
  role: "all",
};

export default function SearchFilterBar({
  values,
  onSearch,
  onReset,
}: SearchFilterBarProps) {
  const [draftValues, setDraftValues] =
    useState<SearchFilterValues>(values);

  useEffect(() => {
    setDraftValues(values);
  }, [values]);

  const handleChange = (name: keyof SearchFilterValues, value: string) => {
    setDraftValues((currentValues) => ({
      ...currentValues,
      [name]: value,
    }));
  };

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    onSearch(draftValues);
  };

  const handleReset = () => {
    setDraftValues(initialValues);
    onSearch(initialValues);
    onReset?.();
  };

  return (
    <form
      onSubmit={handleSubmit}
      className="rounded-lg border border-slate-200 bg-white p-4"
    >
      <div className="grid gap-4 lg:grid-cols-[1fr_180px_180px_auto] lg:items-end">
        <div>
          <label
            htmlFor="keyword"
            className="mb-2 block text-sm font-medium text-slate-700"
          >
            キーワード
          </label>

          <input
            id="keyword"
            type="search"
            value={draftValues.keyword}
            onChange={(event) =>
              handleChange("keyword", event.target.value)
            }
            placeholder="名前・メールアドレスで検索"
            className="h-11 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-primary-500 focus:bg-white"
          />
        </div>

        <div>
          <label
            htmlFor="status"
            className="mb-2 block text-sm font-medium text-slate-700"
          >
            ステータス
          </label>

          <select
            id="status"
            value={draftValues.status}
            onChange={(event) =>
              handleChange("status", event.target.value)
            }
            className="h-11 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition focus:border-primary-500 focus:bg-white"
          >
            <option value="all">すべて</option>
            <option value="active">有効</option>
            <option value="pending">承認待ち</option>
            <option value="suspended">停止中</option>
          </select>
        </div>

        <div>
          <label
            htmlFor="role"
            className="mb-2 block text-sm font-medium text-slate-700"
          >
            権限
          </label>

          <select
            id="role"
            value={draftValues.role}
            onChange={(event) =>
              handleChange("role", event.target.value)
            }
            className="h-11 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition focus:border-primary-500 focus:bg-white"
          >
            <option value="all">すべて</option>
            <option value="admin">管理者</option>
            <option value="editor">編集者</option>
            <option value="viewer">閲覧者</option>
          </select>
        </div>

        <div className="flex gap-2">
          <button
            type="submit"
            className="h-11 rounded-lg bg-primary-500 px-4 text-sm font-medium text-white transition hover:bg-primary-700"
          >
            検索
          </button>

          <button
            type="button"
            onClick={handleReset}
            className="h-11 rounded-lg border border-slate-200 bg-white px-4 text-sm font-medium text-slate-600 transition hover:bg-slate-50"
          >
            リセット
          </button>
        </div>
      </div>
    </form>
  );
}

ポイントは、values を受け取るようにしたことです。

type SearchFilterBarProps = {
  values: SearchFilterValues;
  onSearch: (values: SearchFilterValues) => void;
  onReset?: () => void;
};

そして、URLクエリが変わった場合に入力欄も更新できるように、useEffect で同期しています。

useEffect(() => {
  setDraftValues(values);
}, [values]);

この形にしておくと、ブラウザバックなどでURLが変わった場合にも、検索フォームの表示を合わせやすくなります。


URLクエリを読み取る

次に、app/page.tsx 側でURLクエリを読み取ります。

Next.js App Routerでは、クライアントコンポーネントで以下を使います。

import {
  usePathname,
  useRouter,
  useSearchParams,
} from "next/navigation";

役割は以下のようになっています。

  • useSearchParams  
    現在のURLクエリを取得する

  • useRouter  
    URLを更新する

  • usePathname  
    現在のパスを取得する


クエリ値を安全に扱う関数を用意する

URLクエリは文字列なので、page や pageSize は数値に変換する必要があります。

まず、数値変換用の関数を用意します。

function toPositiveNumber(value: string | null, fallback: number) {
  const numberValue = Number(value);

  if (!Number.isFinite(numberValue) || numberValue < 1) {
    return fallback;
  }

  return numberValue;
}

page=abc のような値が入っていても、fallbackの値を使うようにします。


URLクエリから検索条件を作る

URLクエリから検索条件を作ります。

const filters: SearchFilterValues = {
  keyword: searchParams.get("keyword") ?? "",
  status: searchParams.get("status") ?? "all",
  role: searchParams.get("role") ?? "all",
};

keyword がなければ空文字。
status と role がなければ "all" にします。

ページ番号と表示件数も取得します。

const page = toPositiveNumber(searchParams.get("page"), 1);
const pageSize = toPositiveNumber(searchParams.get("pageSize"), 5);

URLクエリを更新する関数を作る

次に、URLクエリを更新する関数を作ります。

const updateQuery = (nextValues: {
  keyword?: string;
  status?: string;
  role?: string;
  page?: number;
  pageSize?: number;
}) => {
  const params = new URLSearchParams(searchParams.toString());

  if (nextValues.keyword !== undefined) {
    const keyword = nextValues.keyword.trim();

    if (keyword) {
      params.set("keyword", keyword);
    } else {
      params.delete("keyword");
    }
  }

  if (nextValues.status !== undefined) {
    if (nextValues.status === "all") {
      params.delete("status");
    } else {
      params.set("status", nextValues.status);
    }
  }

  if (nextValues.role !== undefined) {
    if (nextValues.role === "all") {
      params.delete("role");
    } else {
      params.set("role", nextValues.role);
    }
  }

  if (nextValues.page !== undefined) {
    if (nextValues.page <= 1) {
      params.delete("page");
    } else {
      params.set("page", String(nextValues.page));
    }
  }

  if (nextValues.pageSize !== undefined) {
    if (nextValues.pageSize === 5) {
      params.delete("pageSize");
    } else {
      params.set("pageSize", String(nextValues.pageSize));
    }
  }

  const queryString = params.toString();

  router.push(queryString ? `${pathname}?${queryString}` : pathname);
};

ポイントは、初期値と同じ値の場合はURLから削除していることです。

たとえば、status が "all" の場合はURLに入れません。

if (nextValues.status === "all") {
  params.delete("status");
}

このようにしておくと、URLが必要以上に長くなりません。


DataTableとPaginationに連動する

ここから、これまで作成した DataTable と Pagination に組み込みます。

app/page.tsx

"use client";

import { useMemo } from "react";
import {
  usePathname,
  useRouter,
  useSearchParams,
} from "next/navigation";
import DashboardLayout from "@/components/dashboard/DashboardLayout";
import DataTable, { DataTableColumn } from "@/components/ui/DataTable";
import Pagination from "@/components/ui/Pagination";
import SearchFilterBar, {
  SearchFilterValues,
} from "@/components/ui/SearchFilterBar";
import StatusBadge from "@/components/ui/StatusBadge";

type User = {
  id: number;
  name: string;
  email: string;
  role: "admin" | "editor" | "viewer";
  status: "active" | "pending" | "suspended";
};

const users: User[] = [
  {
    id: 1,
    name: "山田 太郎",
    email: "yamada@example.com",
    role: "admin",
    status: "active",
  },
  {
    id: 2,
    name: "佐藤 花子",
    email: "sato@example.com",
    role: "editor",
    status: "pending",
  },
  {
    id: 3,
    name: "田中 一郎",
    email: "tanaka@example.com",
    role: "viewer",
    status: "suspended",
  },
  {
    id: 4,
    name: "鈴木 次郎",
    email: "suzuki@example.com",
    role: "editor",
    status: "active",
  },
  {
    id: 5,
    name: "高橋 美咲",
    email: "takahashi@example.com",
    role: "viewer",
    status: "pending",
  },
  {
    id: 6,
    name: "伊藤 健",
    email: "ito@example.com",
    role: "admin",
    status: "active",
  },
  {
    id: 7,
    name: "渡辺 亮",
    email: "watanabe@example.com",
    role: "viewer",
    status: "suspended",
  },
  {
    id: 8,
    name: "中村 彩",
    email: "nakamura@example.com",
    role: "editor",
    status: "active",
  },
  {
    id: 9,
    name: "小林 翔",
    email: "kobayashi@example.com",
    role: "viewer",
    status: "pending",
  },
  {
    id: 10,
    name: "加藤 優",
    email: "kato@example.com",
    role: "admin",
    status: "active",
  },
  {
    id: 11,
    name: "吉田 葵",
    email: "yoshida@example.com",
    role: "editor",
    status: "active",
  },
  {
    id: 12,
    name: "山本 大輔",
    email: "yamamoto@example.com",
    role: "viewer",
    status: "suspended",
  },
];

function toPositiveNumber(value: string | null, fallback: number) {
  const numberValue = Number(value);

  if (!Number.isFinite(numberValue) || numberValue < 1) {
    return fallback;
  }

  return numberValue;
}

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>;
  }
}

function renderRole(role: User["role"]) {
  switch (role) {
    case "admin":
      return "管理者";
    case "editor":
      return "編集者";
    case "viewer":
      return "閲覧者";
  }
}

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">{renderRole(user.role)}</span>
    ),
  },
  {
    key: "status",
    header: "ステータス",
    render: (user) => renderStatus(user.status),
  },
];

export default function Page() {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  const filters: SearchFilterValues = {
    keyword: searchParams.get("keyword") ?? "",
    status: searchParams.get("status") ?? "all",
    role: searchParams.get("role") ?? "all",
  };

  const requestedPage = toPositiveNumber(searchParams.get("page"), 1);
  const pageSize = toPositiveNumber(searchParams.get("pageSize"), 5);

  const filteredUsers = useMemo(() => {
    return users.filter((user) => {
      const keyword = filters.keyword.trim().toLowerCase();

      const matchesKeyword =
        keyword.length === 0 ||
        user.name.toLowerCase().includes(keyword) ||
        user.email.toLowerCase().includes(keyword);

      const matchesStatus =
        filters.status === "all" || user.status === filters.status;

      const matchesRole =
        filters.role === "all" || user.role === filters.role;

      return matchesKeyword && matchesStatus && matchesRole;
    });
  }, [filters.keyword, filters.status, filters.role]);

  const totalItems = filteredUsers.length;
  const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
  const currentPage = Math.min(requestedPage, totalPages);

  const currentUsers = useMemo(() => {
    const start = (currentPage - 1) * pageSize;
    const end = start + pageSize;

    return filteredUsers.slice(start, end);
  }, [filteredUsers, currentPage, pageSize]);

  const updateQuery = (nextValues: {
    keyword?: string;
    status?: string;
    role?: string;
    page?: number;
    pageSize?: number;
  }) => {
    const params = new URLSearchParams(searchParams.toString());

    if (nextValues.keyword !== undefined) {
      const keyword = nextValues.keyword.trim();

      if (keyword) {
        params.set("keyword", keyword);
      } else {
        params.delete("keyword");
      }
    }

    if (nextValues.status !== undefined) {
      if (nextValues.status === "all") {
        params.delete("status");
      } else {
        params.set("status", nextValues.status);
      }
    }

    if (nextValues.role !== undefined) {
      if (nextValues.role === "all") {
        params.delete("role");
      } else {
        params.set("role", nextValues.role);
      }
    }

    if (nextValues.page !== undefined) {
      if (nextValues.page <= 1) {
        params.delete("page");
      } else {
        params.set("page", String(nextValues.page));
      }
    }

    if (nextValues.pageSize !== undefined) {
      if (nextValues.pageSize === 5) {
        params.delete("pageSize");
      } else {
        params.set("pageSize", String(nextValues.pageSize));
      }
    }

    const queryString = params.toString();

    router.push(queryString ? `${pathname}?${queryString}` : pathname);
  };

  const handleSearch = (values: SearchFilterValues) => {
    updateQuery({
      ...values,
      page: 1,
    });
  };

  const handlePageChange = (nextPage: number) => {
    updateQuery({
      page: nextPage,
    });
  };

  const handlePageSizeChange = (nextPageSize: number) => {
    updateQuery({
      pageSize: nextPageSize,
      page: 1,
    });
  };

  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">
            検索条件とページネーションをURLクエリと連動した一覧画面です。
            URLを共有したり、再読み込みしても状態を維持できます。
          </p>
        </section>

        <SearchFilterBar
          values={filters}
          onSearch={handleSearch}
        />

        <div className="space-y-4">
          <DataTable
            columns={columns}
            data={currentUsers}
            getRowKey={(user) => user.id}
            emptyMessage="条件に一致するユーザーが見つかりません。"
          />

          <Pagination
            currentPage={currentPage}
            totalPages={totalPages}
            totalItems={totalItems}
            pageSize={pageSize}
            pageSizeOptions={[5, 10, 20]}
            onPageChange={handlePageChange}
            onPageSizeChange={handlePageSizeChange}
          />
        </div>
      </div>
    </DashboardLayout>
  );
}

これで、検索条件、ページ番号、表示件数がURLクエリと連動するようになります。


検索条件をURLに反映する

検索ボタンを押したときは、handleSearch が呼ばれます。

const handleSearch = (values: SearchFilterValues) => {
  updateQuery({
    ...values,
    page: 1,
  });
};

検索条件を変更した場合、ページ番号は1に戻しています。

検索結果が変わると総ページ数も変わるため、検索時に1ページ目へ戻すのが自然です。


ページ番号をURLに反映する

ページを変更したときは、page をURLに反映します。

const handlePageChange = (nextPage: number) => {
  updateQuery({
    page: nextPage,
  });
};

たとえば、2ページ目へ移動すると、URLは次のようになります。

/users?page=2

検索条件がある場合は、検索条件を残したままページだけ更新します。

/users?keyword=yamada&status=active&page=2

表示件数をURLに反映する

表示件数を変更した場合は、pageSize をURLに反映します。

const handlePageSizeChange = (nextPageSize: number) => {
  updateQuery({
    pageSize: nextPageSize,
    page: 1,
  });
};

表示件数を変えると総ページ数が変わるため、ここでも1ページ目に戻しています。

たとえば、表示件数を10件にした場合は、URLが次のようになります。

/users?pageSize=10

初期値と同じ場合はURLを短くする

今回の updateQuery では、初期値と同じ場合はURLから削除しています。

たとえば、status が "all" の場合です。

if (nextValues.status === "all") {
  params.delete("status");
}

page が1の場合も削除しています。

if (nextValues.page <= 1) {
  params.delete("page");
}

表示件数が初期値の5件の場合も削除します。

if (nextValues.pageSize === 5) {
  params.delete("pageSize");
}

これにより、初期状態のURLはシンプルになります。

/users

検索条件がある場合だけ、必要なクエリを付けます。

/users?keyword=yamada&status=active

ブラウザバックにも対応しやすくなる

URLクエリと連動すると、ブラウザバックでも前の検索状態に戻りやすくなります。

たとえば、次のように操作したとします。

  1. /users

  2. /users?status=active

  3. /users?status=active&page=2

ブラウザバックを押すと、前のURLに戻ります。

/users?status=active

SearchFilterBar は values をpropsで受け取り、URLクエリが変わると入力欄も更新されるようにしているため、画面表示もURLに追従できます。

useEffect(() => {
  setDraftValues(values);
}, [values]);

実装のポイント

今回のポイントは、一覧画面の状態をURLクエリに寄せたことです。

  • 検索条件  
    keyword / status / role

  • ページネーション  
    page / pageSize

これらをURLに持たせることで、状態を共有しやすくなります。

また、役割を分けているのもポイントです。

  • SearchFilterBar  
    検索条件の入力を担当

  • DataTable  
    絞り込み後のデータを表示

  • Pagination  
    ページ変更と表示件数変更を担当

  • page.tsx  
    URLクエリの読み取りと更新を担当

コンポーネント側にURL操作を持たせすぎず、ページ側でまとめて管理しています。

この方が、他の一覧画面にも展開しやすいです。


まとめ

今回は、Next.jsで管理画面の検索条件とページネーションをURLクエリと連動しました。

今回連動した値は以下です。

  • keyword

  • status

  • role

  • page

  • pageSize

これにより、

  • ページを再読み込みしても検索状態が残る

  • URLを共有できる

  • ブラウザバックで前の状態に戻れる

  • 検索条件とページ番号を一緒に管理できる

ようになります。

これまで作成してきた、

  • SearchFilterBar

  • DataTable

  • Pagination

  • StatusBadge

  • DashboardLayout

を組み合わせることで、管理画面の一覧がかなり実務に近づいてきました。

#NextJS #React #コンポーネント #ページング #検索 #URL #クエリパラメータ #表示

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

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