Chapter 11: 検索とページネーション
ストリーミングで初期表示を改善したら,利用者が情報を探しやすくする UI を整えましょう。この章では /dashboard/invoices に検索フォームとページネーションを実装し,URL パラメータと連動させます。
この章で学ぶこと
- Next.js の API(
useSearchParams,usePathname,useRouter)の使い方 - URL の検索パラメータを使った検索とページネーションの実装
スターターコード
/dashboard/invoices/page.tsx に以下を貼り付けます。
import Pagination from '@/app/ui/invoices/pagination';
import Search from '@/app/ui/search';
import Table from '@/app/ui/invoices/table';
import { CreateInvoice } from '@/app/ui/invoices/buttons';
import { lusitana } from '@/app/ui/fonts';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
import { Suspense } from 'react';
export default async function Page() {
return (
<div className="w-full">
<div className="flex w-full items-center justify-between">
<h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
</div>
<div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
<Search placeholder="Search invoices..." />
<CreateInvoice />
</div>
{/* <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
<Table query={query} currentPage={currentPage} />
</Suspense> */}
<div className="mt-5 flex w-full justify-center">
{/* <Pagination totalPages={totalPages} /> */}
</div>
</div>
);
}
このページで扱うコンポーネントは次のとおりです。
<Search/>:請求書を検索する入力欄<Pagination/>:請求書一覧のページ移動<Table/>:請求書の一覧表示
検索は クライアントとサーバーをまたぐフローになります。クライアントで検索語を入力すると URL パラメータが更新され,サーバーでデータ取得 → テーブルがサーバーで再レンダリングされて新しいデータが表示されます。
なぜ URL 検索パラメータを使うのか
状態管理を URL で行うことには以下の利点があります。
- ブックマーク・共有が容易:検索語やフィルタを URL に含められる
- サーバーサイドレンダリングと相性良し:URL をそのままサーバーで読み取り,初期状態を描画できる
- 分析・トラッキングが簡単:クライアントロジックなしで検索語を計測しやすい
検索機能の実装
この実装では次のクライアントフックを使います。
useSearchParams:現在の URL の検索パラメータへアクセスusePathname:現在のパス名(例:/dashboard/invoices)を取得useRouter:クライアントコンポーネントからプログラム的に遷移(replaceなど)
実装ステップは以下のとおりです。
- ユーザー入力の取得
- 検索語で URL を更新
- URL と入力欄の 同期
- テーブルを 検索語に合わせて 更新
1. ユーザー入力を取得
/app/ui/search.tsx を開きます。これは "use client" のクライアントコンポーネントで,<input> が検索欄です。handleSearch を作成し,onChange から呼び出します。
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
export default function Search({ placeholder }: { placeholder: string }) {
function handleSearch(term: string) {
console.log(term);
}
return (
<div className="relative flex flex-1 flex-shrink-0">
<label htmlFor="search" className="sr-only">
Search
</label>
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
placeholder={placeholder}
onChange={(e) => {
handleSearch(e.target.value);
}}
/>
<MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
);
}
ブラウザのコンソールで入力がログ出力されることを確認します。
2. URL を検索パラメータで更新
useSearchParams を読み込み,handleSearch 内で URLSearchParams を使ってパラメータを操作します。
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';
export default function Search() {
const searchParams = useSearchParams();
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams);
}
// ...
}
入力が空なら削除,値があれば query をセットします。
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';
export default function Search() {
const searchParams = useSearchParams();
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
}
}
続けて useRouter と usePathname を使い,URL を replace で更新します。
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
export default function Search() {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}
}
${pathname}は現在のパス(例:/dashboard/invoices)params.toString()が?query=leeのような文字列に変換replace()により,ページリロードなしで URL を更新(クライアントサイドナビゲーション)
3. URL と入力欄の同期
共有時などにも入力が反映されるよう,defaultValue に URL から取得した値を渡します。
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
placeholder={placeholder}
onChange={(e) => {
handleSearch(e.target.value);
}}
defaultValue={searchParams.get('query')?.toString()}
/>
defaultValue vs value(制御/非制御) ステートで制御する場合は
valueを使います。ここでは URL に状態を保存しているため,非制御のdefaultValueで十分です。
4. テーブルを更新
ページコンポーネントは searchParams プロップを受け取れます。これを使って <Table> に query と currentPage を渡します。
import Pagination from '@/app/ui/invoices/pagination';
import Search from '@/app/ui/search';
import Table from '@/app/ui/invoices/table';
import { CreateInvoice } from '@/app/ui/invoices/buttons';
import { lusitana } from '@/app/ui/fonts';
import { Suspense } from 'react';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
export default async function Page(props: {
searchParams?: Promise<{
query?: string;
page?: string;
}>;
}) {
const searchParams = await props.searchParams;
const query = searchParams?.query || '';
const currentPage = Number(searchParams?.page) || 1;
return (
<div className="w-full">
<div className="flex w-full items-center justify-between">
<h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
</div>
<div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
<Search placeholder="Search invoices..." />
<CreateInvoice />
</div>
<Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
<Table query={query} currentPage={currentPage} />
</Suspense>
<div className="mt-5 flex w-full justify-center">
{/* <Pagination totalPages={totalPages} /> */}
</div>
</div>
);
}
<Table> は受け取った query と currentPage を fetchFilteredInvoices() に渡してデータを取得します。
// ...
export default async function InvoicesTable({
query,
currentPage,
}: {
query: string;
currentPage: number;
}) {
const invoices = await fetchFilteredInvoices(query, currentPage);
// ...
}
これで,入力 → URL 更新 → サーバーでデータ取得 → テーブル再レンダリング,という流れが動作します。
フックと
searchParamsの使い分け
- クライアントで読む:
useSearchParams()(例:<Search>)- サーバーで読む:ページの
searchParamsプロップ(例:<Table>に渡す)
ベストプラクティス:デバウンス
現状だと入力のたびに URL を更新し,都度 DB をクエリしています。小規模アプリなら問題ありませんが,ユーザー数が多いと負荷になります。デバウンスでリクエスト頻度を抑えましょう。
use-debounce を導入します。
useDebouncedCallback を使って,入力停止後 300ms 経ってから URL を更新します。
// ...
import { useDebouncedCallback } from 'use-debounce';
// Inside the Search Component...
const handleSearch = useDebouncedCallback((term) => {
console.log(`Searching... ${term}`);
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}, 300);
これでタイプ終了後にだけクエリが実行され,DB へのリクエスト数を削減できます。
ページネーションの追加
fetchFilteredInvoices() は 1ページ6件 まで返します。全件を閲覧できるよう,検索と同様に URL パラメータでページネーションを実装します。
<Pagination> はクライアントコンポーネントですが,データ取得はサーバーで行い,結果(総ページ数)だけをプロップで渡します。
まず,ページで fetchInvoicesPages(query) を呼び出し,totalPages を取得します。
// ...
import { fetchInvoicesPages } from '@/app/lib/data';
export default async function Page(
props: {
searchParams?: Promise<{
query?: string;
page?: string;
}>;
}
) {
const searchParams = await props.searchParams;
const query = searchParams?.query || '';
const currentPage = Number(searchParams?.page) || 1;
const totalPages = await fetchInvoicesPages(query);
return (
// ...
);
}
次に,<Pagination> に渡します。
// ...
export default async function Page(props: {
searchParams?: Promise<{
query?: string;
page?: string;
}>;
}) {
const searchParams = await props.searchParams;
const query = searchParams?.query || '';
const currentPage = Number(searchParams?.page) || 1;
const totalPages = await fetchInvoicesPages(query);
return (
<div className="w-full">
<div className="flex w-full items-center justify-between">
<h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
</div>
<div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
<Search placeholder="Search invoices..." />
<CreateInvoice />
</div>
<Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
<Table query={query} currentPage={currentPage} />
</Suspense>
<div className="mt-5 flex w-full justify-center">
<Pagination totalPages={totalPages} />
</div>
</div>
);
}
<Pagination> 側で現在ページを読み取り,新しいページ番号の URL を生成します。
'use client';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Link from 'next/link';
import { generatePagination } from '@/app/lib/utils';
import { usePathname, useSearchParams } from 'next/navigation';
export default function Pagination({ totalPages }: { totalPages: number }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentPage = Number(searchParams.get('page')) || 1;
const createPageURL = (pageNumber: number | string) => {
const params = new URLSearchParams(searchParams);
params.set('page', pageNumber.toString());
return `${pathname}?${params.toString()}`;
};
// ...(スタイルや状態制御の実装は省略。適宜アンコメント)
}
最後に,新しい検索語を入力したらページ番号を 1 にリセットします。<Search> の handleSearch を更新します。
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';
export default function Search({ placeholder }: { placeholder: string }) {
const searchParams = useSearchParams();
const { replace } = useRouter();
const pathname = usePathname();
const handleSearch = useDebouncedCallback((term) => {
const params = new URLSearchParams(searchParams);
params.set('page', '1');
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}, 300);
}
まとめ
おつかれさまです!URL 検索パラメータと Next.js の API を使って,検索とページネーションを実装できました。
- クライアントステートの代わりに URL 検索パラメータで状態を扱い,ブックマーク/共有/SSR を容易にした。
- データ取得はサーバーで実行。
useRouterを使い,スムーズなクライアント遷移を実現。
クライアントサイド React に慣れていると少し異なるパターンですが,URL で状態を管理し,サーバーで初期描画する利点が理解できたはずです。次へ進みましょう。