コンテンツにスキップ

11

検索とページネーションの追加

前の章では、ストリーミングを使ってダッシュボードの初期ロードパフォーマンスを改善しました。今回は、/invoices ページに移動し、検索とページネーションの追加方法を学びます。

このチャプターでは...

取り上げるトピックは以下の通りです。

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

しばらく時間をかけて、ページと連携するコンポーネントに慣れてください。

  1. <Search/> は、ユーザーが特定の請求書を検索できるようにします。
  2. <Pagination/> は、ユーザーが請求書のページ間を移動できるようにします。
  3. <Table/> は、請求書を表示します。

検索機能は、クライアントとサーバーにまたがります。ユーザーがクライアントで請求書を検索すると、URLパラメータが更新され、サーバーでデータが取得され、テーブルは新しいデータでサーバーで再レンダリングされます。

URL検索パラメータを使用する理由

前述のように、検索状態を管理するためにURL検索パラメータを使用します。クライアントサイドの状態管理に慣れている場合、このパターンは新しいかもしれません。

URLパラメータで検索を実装する利点はいくつかあります。

  • ブックマーク可能で共有可能なURL: 検索パラメータはURLに含まれているため、ユーザーはアプリケーションの現在の状態(検索クエリやフィルターを含む)をブックマークして、後で参照したり共有したりできます。
  • サーバーサイドレンダリング: URLパラメータはサーバーで直接消費して初期状態をレンダリングできるため、サーバーレンダリングの処理が容易になります。
  • 分析とトラッキング: 検索クエリとフィルターがURLに直接含まれているため、追加のクライアントサイドロジックを必要とせずにユーザー行動を追跡しやすくなります。

検索機能の追加

検索機能を実装するために使用するNext.jsのクライアントフックは次のとおりです。

  • useSearchParams- 現在のURLのパラメータにアクセスできます。たとえば、このURL /dashboard/invoices?page=1&query=pending の検索パラメータは次のようになります: {page: '1', query: 'pending'}
  • usePathname - 現在のURLのパス名(pathname)を読み取ることができます。たとえば、ルート /dashboard/invoices の場合、usePathname'/dashboard/invoices' を返します。
  • useRouter - クライアントコンポーネント内でプログラムでルート間を移動できるようにします。使用できる 方法は複数 あります。

実装手順の概要は次のとおりです。

  1. ユーザーの入力をキャプチャします。
  2. 検索パラメータでURLを更新します。
  3. URLを入力フィールドと同期させます。
  4. テーブルを検索クエリに合わせて更新します。

1. ユーザーの入力をキャプチャする

<Search> コンポーネント (/app/ui/search.tsx) を開くと、次の点に気づくでしょう。

  • "use client" - これはクライアントコンポーネントであり、イベントリスナーとフックを使用できます。
  • <input> - これは検索入力フィールドです。

新しい handleSearch 関数を作成し、<input> 要素に onChange リスナーを追加します。onChange は、入力値が変更されるたびに handleSearch を呼び出します。

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

ブラウザの開発者ツールのコンソールを開き、検索フィールドに何か入力して、正しく機能していることを確認してください。検索語がブラウザコンソールにログされるはずです。

よし!ユーザーの検索入力をキャプチャできました。次に、URLを検索語で更新する必要があります。

2. URLを検索パラメータで更新する

next/navigation から useSearchParams フックをインポートし、変数に割り当てます。

/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) {
    console.log(term);
  }
  // ...
}

handleSearch の内部で、新しい searchParams 変数を使用して 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);
  }
  // ...
}

URLSearchParams は、URLクエリパラメータを操作するためのユーティリティメソッドを提供するWeb APIです。複雑な文字列リテラルを作成する代わりに、それを使用して ?page=1&query=a のようなパラメータ文字列を取得できます。

次に、ユーザーの入力に基づいてパラメータ文字列を set します。入力が空の場合は、それを delete したいです。

/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');
    }
  }
  // ...
}

クエリ文字列ができたので、Next.jsの useRouter および usePathname フックを使用してURLを更新できます。

'next/navigation' から useRouterusePathname をインポートし、handleSearch 内で useRouter()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() はこの入力をURLフレンドリーな形式に変換します。
  • replace(${pathname}?${params.toString()}) は、ユーザーの検索データでURLを更新します。たとえば、ユーザーが "Lee" を検索した場合、/dashboard/invoices?query=lee となります。
  • Next.jsのクライアントサイドナビゲーション(ページ間のナビゲーションの章で学習しました)のおかげで、ページをリロードせずにURLが更新されます。

3. URLと入力の同期を維持する

入力フィールドがURLと同期し、共有時に入力されるようにするには、searchParams から読み取って、入力に defaultValue を渡すことができます。

/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 / コントロールされた vs. アンコントロールされた

入力の値の管理に状態を使用している場合、value 属性を使用してそれをコントロールされたコンポーネントにします。これは、React が入力の状態を管理することを意味します。

ただし、状態を使用していないため、defaultValue を使用できます。これは、ネイティブ入力が独自の状態を管理することを意味します。検索クエリを状態ではなくURLに保存しているため、これは問題ありません。

4. テーブルの更新

最後に、テーブルコンポーネントを検索クエリに合わせて更新する必要があります。

請求書ページに移動します。

ページコンポーネントは searchParams という名前のプロップを受け取るため、現在のURLパラメータを <Table> コンポーネントに渡すことができます。

/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> コンポーネントに移動すると、querycurrentPage という 2 つのプロップが fetchFilteredInvoices() 関数に渡され、クエリに一致する請求書が返されることがわかります。

/app/ui/invoices/table.tsx
// ...
export default async function InvoicesTable({
  query,
  currentPage,
}: {
  query: string;
  currentPage: number;
}) {
  const invoices = await fetchFilteredInvoices(query, currentPage);
  // ...
}

これらの変更を適用して、テストしてみてください。用語を検索すると、URLが更新され、サーバーに新しいリクエストが送信され、サーバーでデータが取得され、クエリに一致する請求書のみが返されます。

useSearchParams() フックと searchParams プロップを使い分けるタイミングは?

検索パラメータを抽出する 2 つの方法があることに気づいたかもしれません。どちらを使用するかは、クライアントで作業しているか、サーバーで作業しているかによって異なります。

  • <Search> はクライアントコンポーネントなので、クライアントからパラメータにアクセスするために useSearchParams() フックを使用しました。
  • <Table> は独自のデータを取得するサーバーコンポーネントなので、ページからコンポーネントに searchParams プロップを渡すことができます。

一般的に、クライアントからパラメータを読み取りたい場合は、サーバーに戻る必要がなくなるため、useSearchParams() フックを使用してください。

ベストプラクティス: デバウンス

おめでとうございます!Next.js で検索機能を実装しました!しかし、最適化できる点があります。

handleSearch 関数内に、次の console.log を追加してください。

/app/ui/search.tsx
function handleSearch(term: string) {
  console.log(`Searching... ${term}`);
 
  const params = new URLSearchParams(searchParams);
  if (term) {
    params.set('query', term);
  } else {
    params.delete('query');
  }
  replace(`${pathname}?${params.toString()}`);
}

次に、検索バーに "Delba" と入力し、開発者ツールのコンソールを確認してください。何が起こっていますか?

開発者ツールコンソール
Searching... D
Searching... De
Searching... Del
Searching... Delb
Searching... Delba

キーストロークごとに URL を更新しているため、キーストロークごとにデータベースにクエリを実行しています!アプリケーションが小さい場合は問題ありませんが、アプリケーションに数千人のユーザーがいて、それぞれがキーストロークごとにデータベースに新しいリクエストを送信していると想像してみてください。

デバウンス は、関数が実行されるレートを制限するプログラミングプラクティスです。この場合、ユーザーがタイピングを停止したときにのみデータベースにクエリを実行したいです。

デバウンスの仕組み

  1. トリガーイベント: デバウンスされるべきイベント(検索ボックスのキーストロークなど)が発生すると、タイマーが開始されます。
  2. 待機: タイマーが期限切れになる前に新しいイベントが発生した場合、タイマーはリセットされます。
  3. 実行: タイマーがカウントダウンの終了に達した場合、デバウンスされた関数が実行されます。

デバウンスはいくつかの方法で実装できます。独自のデバウンス関数を自分で作成することも含みます。簡単にするために、use-debounce というライブラリを使用します。

use-debounce をインストールする

ターミナル
pnpm i use-debounce

<Search> コンポーネントで、useDebouncedCallback という名前の関数をインポートします。

/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);

この関数は handleSearch の内容をラップし、ユーザーがタイピングを停止してから特定の時間(300ミリ秒)後にのみコードを実行します。

再度検索バーに入力し、開発者ツールのコンソールを開いてください。次のようになるはずです。

開発者ツールコンソール
Searching... Delba

デバウンスすることにより、データベースに送信されるリクエストの数を減らし、リソースを節約できます。

ページネーションの追加

検索機能の導入後、テーブルは一度に6つの請求書しか表示されないことに気づくでしょう。これは、data.tsfetchFilteredInvoices() 関数が1ページあたり最大6つの請求書を返すためです。

ページネーションを追加すると、ユーザーはさまざまなページを移動してすべての請求書を表示できます。検索と同様に、URLパラメータを使用してページネーションを実装する方法を見てみましょう。

<Pagination/> コンポーネントに移動すると、それがクライアントコンポーネントであることがわかります。クライアントでデータを取得したくありません。これはデータベースの秘密を公開してしまうからです(APIレイヤーを使用していないことを思い出してください)。代わりに、サーバーでデータを取得し、プロップとしてコンポーネントに渡すことができます。

/dashboard/invoices/page.tsx で、fetchInvoicesPages という名前の新しい関数をインポートし、searchParams からの query を引数として渡します。

/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 (
    // ...
  );
}

fetchInvoicesPages は、検索クエリに基づいてページの総数を返します。たとえば、検索クエリに一致する請求書が12件あり、各ページに6件の請求書が表示される場合、総ページ数は2になります。

次に、totalPages プロップを <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/> コンポーネントに移動し、usePathnameuseSearchParams フックをインポートします。これらを使用して現在のページを取得し、新しいページを設定します。また、このコンポーネントのコードのコメントアウトを解除してください。<Pagination/> のロジックをまだ実装していないため、アプリケーションは一時的に壊れます。今、それを実装しましょう!

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

次に、<Pagination> コンポーネント内に createPageURL という名前の新しい関数を作成します。検索と同様に、URLSearchParams を使用して新しいページ番号を設定し、pathName を使用して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()}`;
  };
 
  // ...
}

何が起こっているのかの概要は次のとおりです。

  • createPageURL は、現在の検索パラメータのインスタンスを作成します。
  • その後、"page" パラメータを指定されたページ番号に更新します。
  • 最後に、パス名と更新された検索パラメータを使用して完全なURLを構築します。

<Pagination> コンポーネントの残りの部分は、スタイリングとさまざまな状態(最初、最後、アクティブ、無効など)を処理します。このコースでは詳細には触れませんが、createPageURL がどこで呼び出されているかを確認するためにコードを自由に確認してください。

最後に、ユーザーが新しい検索クエリを入力したときに、ページ番号を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検索パラメータで検索とページネーションを処理しました。
  • サーバーでデータを取得しました。
  • よりスムーズなクライアントサイド遷移のために、useRouter ルーターフックを使用しています。

これらのパターンは、クライアントサイド React で作業しているときに慣れていることとは異なるかもしれませんが、URL検索パラメータを使用することの利点と、この状態をサーバーにリフトすることの利点を、よりよく理解していただけたことと思います。

チャプターを完了しました。11

ダッシュボードに検索とページネーション機能が追加されました!

次へ

12: データの変更

サーバーアクションでデータを変更する方法を学びます。

App Router: 検索とページネーションの追加 | Next.js