【Next.js】管理画面で使える検索・絞り込みバーを作成
こんにちは、ライトです。
前回は、管理画面で使える Pagination コンポーネントを作成しました。
その前には、一覧表示用の DataTable、状態表示用の StatusBadge も作成しました。
ここまでで、管理画面の一覧に必要な基本要素として、
データを表示する
状態をバッジで表示する
ページを切り替える
表示件数を変更する
ところまでは作れました。
次に必要になるのは、一覧から目的のデータを探すための 検索・絞り込み です。
管理画面では、データ件数が増えてくると、ただ一覧を表示するだけでは使いにくくなります。
たとえば、ユーザー一覧であれば、
名前で検索したい
メールアドレスで検索したい
ステータスで絞り込みたい
権限で絞り込みたい
検索条件をリセットしたい
といった操作が必要になります。
そこで今回は、管理画面で使いやすい 検索・絞り込みバー を作成します。
今回作成するもの
今回は、以下のように使える SearchFilterBar コンポーネントを作成します。
<SearchFilterBar
onSearch={(values) => {
console.log(values);
}}
onReset={() => {
console.log("リセット");
}}
/>今回の検索・絞り込みバーでは、以下の項目を扱います。
キーワード検索
ステータス絞り込み
権限絞り込み
検索ボタン
リセットボタン
ユーザー一覧を想定して、以下のような検索条件にします。
type SearchFilterValues = {
keyword: string;
status: string;
role: string;
};keyword には名前やメールアドレスを入力します。
status では「有効」「承認待ち」「停止中」などを絞り込みます。
role では「管理者」「編集者」「閲覧者」などを絞り込みます。
完成形
以下のような検索・絞り込みバーを作成します。

PCでは横並びに表示し、画面幅が狭い場合は縦に並ぶようにします。
ファイル構成
今回は、以下の構成で作成します。
components/
ui/
SearchFilterBar.tsx
app/
page.tsxSearchFilterBar は一覧画面で使い回すUIなので、components/ui/ に作成します。
SearchFilterBarで実装する機能
今回の SearchFilterBar では、以下を実装します。
キーワード入力
ステータス選択
権限選択
検索ボタン
リセットボタン
入力値のstate管理
検索条件を親コンポーネントに渡す
リセット時に初期状態へ戻す
検索・絞り込みバー自体は、データの絞り込み処理までは行いません。
あくまで、
検索条件を入力する
検索ボタンを押す
親コンポーネントに条件を渡す
ところまでを担当します。
実際にデータを絞り込む処理は、親コンポーネント側で行います。
SearchFilterBar.tsxを作成する
それでは、SearchFilterBar.tsx を作成します。
components/ui/SearchFilterBar.tsx
"use client";
import { FormEvent, useState } from "react";
export type SearchFilterValues = {
keyword: string;
status: string;
role: string;
};
type SearchFilterBarProps = {
defaultValues?: Partial<SearchFilterValues>;
onSearch: (values: SearchFilterValues) => void;
onReset?: () => void;
};
const initialValues: SearchFilterValues = {
keyword: "",
status: "all",
role: "all",
};
export default function SearchFilterBar({
defaultValues,
onSearch,
onReset,
}: SearchFilterBarProps) {
const [values, setValues] = useState<SearchFilterValues>({
...initialValues,
...defaultValues,
});
const handleChange = (name: keyof SearchFilterValues, value: string) => {
setValues((currentValues) => ({
...currentValues,
[name]: value,
}));
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
onSearch(values);
};
const handleReset = () => {
const resetValues = {
...initialValues,
...defaultValues,
};
setValues(resetValues);
onSearch(resetValues);
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={values.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={values.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={values.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>
);
}これで、検索・絞り込みバーの基本形ができました。
propsの役割
SearchFilterBar で受け取るpropsは以下です。
type SearchFilterBarProps = {
defaultValues?: Partial<SearchFilterValues>;
onSearch: (values: SearchFilterValues) => void;
onReset?: () => void;
};それぞれの役割は次の通りです。
defaultValues
初期値を指定するonSearch
検索ボタンを押したときに呼ばれるonReset
リセット時に追加で実行したい処理
defaultValues は任意です。
defaultValues?: Partial<SearchFilterValues>;指定しない場合は、以下の初期値が使われます。
const initialValues: SearchFilterValues = {
keyword: "",
status: "all",
role: "all",
};status や role は、すべてを表す値として "all" を使っています。
入力値を管理する
入力値は、SearchFilterBar 内部の useState で管理しています。
const [values, setValues] = useState<SearchFilterValues>({
...initialValues,
...defaultValues,
});入力値を更新するときは、handleChange を使います。
const handleChange = (name: keyof SearchFilterValues, value: string) => {
setValues((currentValues) => ({
...currentValues,
[name]: value,
}));
};たとえば、キーワード入力では次のように使っています。
<input
value={values.keyword}
onChange={(event) => handleChange("keyword", event.target.value)}
/>ステータスや権限のセレクトボックスも同じ考え方です。
<select
value={values.status}
onChange={(event) => handleChange("status", event.target.value)}
>検索ボタンを押したときの処理
検索ボタンを押すと、フォームの onSubmit が実行されます。
<form onSubmit={handleSubmit}>handleSubmit では、ページの再読み込みを防いでから、親コンポーネントへ検索条件を渡します。
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
onSearch(values);
};これにより、親コンポーネント側で検索処理を実行できます。
<SearchFilterBar
onSearch={(values) => {
console.log(values);
}}
/>リセットボタンを押したときの処理
リセットボタンを押した場合は、検索条件を初期状態に戻します。
const handleReset = () => {
const resetValues = {
...initialValues,
...defaultValues,
};
setValues(resetValues);
onSearch(resetValues);
onReset?.();
};ここでは、内部のstateを戻すだけでなく、onSearch(resetValues) も呼んでいます。
onSearch(resetValues);これにより、親コンポーネント側の検索条件もリセットされます。
追加で何か処理したい場合は、onReset を渡せます。
onReset?.();レスポンシブ対応
検索・絞り込みバーは、PCでは横並びにしたいですが、モバイルでは縦に並べた方が見やすいです。
今回のレイアウトでは、以下のように指定しています。
<div className="grid gap-4 lg:grid-cols-[1fr_180px_180px_auto] lg:items-end">通常は縦に並びます。

lg 以上では横並びになります。

キーワード入力は広く取り、セレクトボックスは固定幅にしています。
管理画面では、検索欄は広く、絞り込み項目は少し狭めにするとバランスが取りやすいです。
DataTableとPaginationに組み合わせる
次に、前回作成した DataTable と Pagination に組み合わせて使ってみます。
今回は、ユーザー一覧を例にします。
app/page.tsx
"use client";
import { useMemo, useState } from "react";
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 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),
},
];
const initialFilters: SearchFilterValues = {
keyword: "",
status: "all",
role: "all",
};
export default function Page() {
const [filters, setFilters] =
useState<SearchFilterValues>(initialFilters);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(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]);
const totalItems = filteredUsers.length;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const currentUsers = useMemo(() => {
const start = (page - 1) * pageSize;
const end = start + pageSize;
return filteredUsers.slice(start, end);
}, [filteredUsers, page, pageSize]);
const handleSearch = (values: SearchFilterValues) => {
setFilters(values);
setPage(1);
};
const handlePageSizeChange = (nextPageSize: number) => {
setPageSize(nextPageSize);
setPage(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">
検索・絞り込みバー、DataTable、Paginationを組み合わせた
管理画面の一覧サンプルです。
</p>
</section>
<SearchFilterBar
defaultValues={initialFilters}
onSearch={handleSearch}
/>
<div className="space-y-4">
<DataTable
columns={columns}
data={currentUsers}
getRowKey={(user) => user.id}
emptyMessage="条件に一致するユーザーが見つかりません。"
/>
<Pagination
currentPage={page}
totalPages={totalPages}
totalItems={totalItems}
pageSize={pageSize}
pageSizeOptions={[5, 10, 20]}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
</div>
</div>
</DashboardLayout>
);
}これで、検索・絞り込み、テーブル表示、ページネーションを組み合わせた一覧画面ができます。
絞り込み処理の考え方
絞り込み処理は、親コンポーネント側で行っています。
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]);キーワードは、名前とメールアドレスに対して検索しています。
user.name.toLowerCase().includes(keyword) ||
user.email.toLowerCase().includes(keyword)ステータスと権限は、all の場合はすべて表示します。
filters.status === "all" || user.status === filters.status
filters.role === "all" || user.role === filters.roleこのようにしておくと、検索条件を追加したい場合も拡張しやすくなります。
検索したら1ページ目に戻す
検索条件を変更した場合は、ページを1ページ目に戻しています。
const handleSearch = (values: SearchFilterValues) => {
setFilters(values);
setPage(1);
};これは、絞り込みによって総ページ数が変わるためです。
たとえば、検索前は3ページ目を見ていたとしても、検索後の結果が1ページ分しかない場合があります。
そのため、検索したタイミングで setPage(1) を実行しています。
表示件数を変更した場合も同じです。
const handlePageSizeChange = (nextPageSize: number) => {
setPageSize(nextPageSize);
setPage(1);
};一覧画面では、検索や表示件数の変更時にページ番号をリセットするのが自然です。
実装のポイント
今回のポイントは、検索・絞り込みバーを単体のコンポーネントとして作ったことです。
SearchFilterBar
検索条件の入力を担当親コンポーネント
検索条件を受け取ってデータを絞り込むDataTable
絞り込み後のデータを表示するPagination
絞り込み後の件数に対してページを切り替える
SearchFilterBar は、データの中身を知りません。
検索条件を入力し、onSearch で親に渡すだけです。
実際にどう絞り込むかは、親コンポーネント側で決めます。
このように役割を分けることで、SearchFilterBar を他の一覧画面でも使い回しやすくなります。
まとめ
今回は、Next.jsで管理画面向けの SearchFilterBar コンポーネントを作成しました。
今回の実装では、以下に対応しました。
キーワード検索
ステータス絞り込み
権限絞り込み
検索ボタン
リセットボタン
レスポンシブ対応
DataTableとの組み合わせ
Paginationとの組み合わせ
回の SearchFilterBar を追加することで、一覧画面がかなり実用的になりました。
次は、検索条件やページ番号をURLクエリと連動させると、さらに管理画面らしい動きにできそうです。
いいなと思ったら応援しよう!
もしよろしければ応援をお願いいたします。
いただいたチップでコーヒーを飲んでがんばります!