🔎

ゼロから学ぶ React, Next.js⑱【Learn Next.js】Chapter11

2024/05/25に公開

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

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

この章で扱うトピック

  • ⏭️ Next.jsのAPI(searchParamsusePathnameuseRouter)の使用方法を学ぶ
  • 🔎 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:検索パラメータはURLにあるため、ユーザーは検索クエリやフィルターを含むアプリケーションの現在の状態をブックマークして、将来の参照や共有に使用できます。
  • サーバーサイドレンダリングと初期読み込み:URLパラメータは、初期状態をレンダリングするためにサーバー上で直接使用できるため、サーバーレンダリングの処理が容易になります。
  • 分析とトラッキング:検索クエリとフィルターをURLに直接含めることで、追加のクライアント側ロジックを必要とせずに、ユーザーの行動を追跡しやすくなります。

検索機能の追加

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

  • useSearchParams - 現在のURLのパラメータにアクセスできます。例えば、/dashboard/invoices?page=1&query=pendingというURLの検索パラメータは次のようになります:{page: '1', query: 'pending'}
  • usePathname - 現在のURLのパス名を読み取ることができます。例えば、/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のuseRouterusePathnameフックを使用して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から読み取ったdefaultValueinputに渡すことができます:

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

4. テーブルの更新

最後に、検索クエリを反映するようにテーブルコンポーネントを更新する必要があります。

請求書ページに戻ります。

pageコンポーネントはsearchParamsというpropを受け取ることができるため、現在の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({
+  searchParams,
+}: {
+  searchParams?: {
+    query?: string;
+   page?: string;
+  };
+}) {
+  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つのpropfetchFilteredInvoices()関数に渡されていることがわかります。この関数は、クエリに一致する請求書を返します。

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

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

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

おめでとうございます!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()}`);
 }

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

Dev Tools Console
Searching... E
Searching... Em
Searching... Emi
Searching... Emil

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

デバウンスは、関数の発火率を制限するプログラミング手法です。この場合、ユーザーが入力を停止したときにのみデータベースにクエリを実行する必要があります。

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

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

use-debounceをインストールします:

Terminal
npm i use-debounce

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

/app/ui/search.tsx
 // ...
+import { useDebouncedCallback } from 'use-debounce';
 
 // Search コンポーネントの中...
+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の内容をラップし、ユーザーが入力を停止した後に特定の時間(300ms)が経過した場合にのみコードを実行します。

ここで、再び検索バーに入力し、開発者ツールでコンソールを開きます。次のように表示されるはずです:

Dev Tools Console
Searching... Emil

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

クイズの時間です!
知識をテストし、学んだことを確認しましょう。

検索機能でデバウンスはどのような問題を解決しますか?

A. データベースクエリを高速化する
B. URLをブックマーク可能にする
C. キーストロークごとに新しいデータベースクエリが実行されるのを防ぐ
D. SEO最適化に役立つ

解答

C. キーストロークごとに新しいデータベースクエリが実行されるのを防ぐ
デバウンスすることで、キーを押すたびに新しいデータベースクエリが発生するのを防ぎ、リソースを節約できる。


ページネーションの追加

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

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

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

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

/app/dashboard/invoices/page.tsx
 // ...
+import { fetchInvoicesPages } from '@/app/lib/data';
 
 export default async function Page({
   searchParams,
 }: {
   searchParams?: {
     query?: string,
     page?: string,
   },
 }) {
   const query = searchParams?.query || '';
   const currentPage = Number(searchParams?.page) || 1;
 
+  const totalPages = await fetchInvoicesPages(query);
 
   return (
     // ...
   );
 }

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

次に、totalPagespropを<Pagination/>コンポーネントに渡します:

/app/dashboard/invoices/page.tsx
 // ...
 
 export default async function Page({
   searchParams,
 }: {
   searchParams?: {
     query?: string;
     page?: string;
   };
 }) {
   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」パラメータを指定されたページ番号に更新します。
  • 最後に、pathnameと更新された検索パラメータを使用して完全な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の検索パラメータを使用し、この状態をサーバーに移行することの利点を理解できたことでしょう。


次の章

https://zenn.dev/gunjo/articles/2de45beb368ab5

Discussion