コンテンツにスキップ

Chapter 11: 検索とページネーション

ストリーミングで初期表示を改善したら,利用者が情報を探しやすくする UI を整えましょう。この章では /dashboard/invoices に検索フォームとページネーションを実装し,URL パラメータと連動させます。


この章で学ぶこと

  • Next.js の API(useSearchParamsusePathnameuseRouter)の使い方
  • URL の検索パラメータを使った検索とページネーションの実装

スターターコード

/dashboard/invoices/page.tsx に以下を貼り付けます。

/app/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 など)

実装ステップは以下のとおりです。

  1. ユーザー入力の取得
  2. 検索語で URL を更新
  3. URL と入力欄の 同期
  4. テーブルを 検索語に合わせて 更新

1. ユーザー入力を取得

/app/ui/search.tsx を開きます。これは "use client" のクライアントコンポーネントで,<input> が検索欄です。handleSearch を作成し,onChange から呼び出します。

/app/ui/search.tsx
'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 を使ってパラメータを操作します。

/app/ui/search.tsx
'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 をセットします。

/app/ui/search.tsx
'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');
    }
  }
}

続けて useRouterusePathname を使い,URL を replace で更新します。

/app/ui/search.tsx
'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 から取得した値を渡します。

/app/ui/search.tsx
<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>querycurrentPage を渡します。

/app/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 { 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> は受け取った querycurrentPagefetchFilteredInvoices() に渡してデータを取得します。

/app/ui/invoices/table.tsx
// ...
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 を導入します。

Terminal
pnpm i use-debounce

useDebouncedCallback を使って,入力停止後 300ms 経ってから URL を更新します。

/app/ui/search.tsx
// ...
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 を取得します。

/app/dashboard/invoices/page.tsx
// ...
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> に渡します。

/app/dashboard/invoices/page.tsx
// ...
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 を生成します。

/app/ui/invoices/pagination.tsx
'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 を更新します。

/app/ui/search.tsx
'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 で状態を管理し,サーバーで初期描画する利点が理解できたはずです。次へ進みましょう。