ゼロから学ぶ React, Next.js⑱【Learn Next.js】Chapter11
【Chapter11】 検索とページネーションの追加
前の章では、ストリーミングを使用してダッシュボードの初期読み込みパフォーマンスを改善しました。次は、/invoices
ページに移動して、検索とページネーションを追加する方法を学びましょう!
この章で扱うトピック
- ⏭️ Next.jsのAPI(
searchParams
、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:検索パラメータは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
- クライアントコンポーネント内でプログラムによってルート間のナビゲーションを可能にします。使用できるメソッドは複数あります。
実装の手順の概要は次のとおりです:
- ユーザーの入力をキャプチャする。
- 検索パラメータでURLを更新する。
- 入力フィールドとURLを同期させる。
- 検索クエリを反映するようにテーブルを更新する。
1. ユーザーの入力をキャプチャする
<Search>
コンポーネント(/app/ui/search.tsx
)に移動すると、以下のことがわかります:
-
"use client"
- これはクライアントコンポーネントであり、イベントリスナーとフックを使用できることを意味します。 -
<input>
- これは検索入力です。
新しいhandleSearch
関数を作成し、<input>
要素にonChange
リスナーを追加します。onChange
は、入力値が変更されるたびにhandleSearch
を呼び出します。
'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
フックをインポートし、変数に割り当てます:
'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
のインスタンスを作成します。
'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
を使用して削除します:
'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'
からuseRouter
とusePathname
をインポートし、handleSearch
の中でuseRouter()
の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()
はこの入力をURL対応の形式に変換します。 -
replace(${pathname}?${params.toString()})
は、ユーザーの検索データでURLを更新します。例えば、ユーザーが「Lee」を検索すると、/dashboard/invoices?query=lee
になります。
Next.jsのクライアントサイドナビゲーションのおかげで、ページの再読み込みなしでURLが更新されます(ページ間のナビゲーションの章で学習しました)。
3. URLと入力を同期させる
入力フィールドがURLと同期し、共有時に入力されるようにするには、searchParams
から読み取ったdefaultValue
をinput
に渡すことができます:
<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>
コンポーネントに渡すことができます。
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>
コンポーネントに移動すると、query
とcurrentPage
の2つのprop
がfetchFilteredInvoices()
関数に渡されていることがわかります。この関数は、クエリに一致する請求書を返します。
// ...
export default async function InvoicesTable({
query,
currentPage,
}: {
query: string;
currentPage: number;
}) {
const invoices = await fetchFilteredInvoices(query, currentPage);
// ...
}
これらの変更を行ったら、テストしてみてください。用語を検索すると、URLが更新され、サーバーに新しいリクエストが送信され、サーバー上でデータがフェッチされ、クエリに一致する請求書のみが返されます。
ベストプラクティス:デバウンス
おめでとうございます!Next.jsを使用して検索を実装しました!ただし、最適化するために実行できることがあります。
handleSearch
関数の中に、次のconsole.log
を追加します:
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」と入力し、開発者ツールのコンソールを確認します。何が起こっていますか?
Searching... E
Searching... Em
Searching... Emi
Searching... Emil
キーストロークごとにURLを更新しているため、キーストロークごとにデータベースにクエリを実行しています!アプリケーションが小規模なので、これは問題ありません。ただし、アプリケーションに何千人ものユーザーがいて、各ユーザーがキーストロークごとにデータベースに新しいリクエストを送信していると想像してください。
デバウンスは、関数の発火率を制限するプログラミング手法です。この場合、ユーザーが入力を停止したときにのみデータベースにクエリを実行する必要があります。
デバウンスの仕組み
- トリガーイベント:デバウンスする必要があるイベント(検索ボックスでのキーストロークなど)が発生すると、タイマーが開始します。
- 待機:タイマーの期限が切れる前に新しいイベントが発生した場合、タイマーはリセットされます。
- 実行:タイマーがカウントダウンの終わりに達すると、デバウンスされた関数が実行されます。
独自のデバウンス関数を手動で作成するなど、いくつかの方法でデバウンスを実装できます。簡単にするために、use-debounce
というライブラリを使用します。
use-debounce
をインストールします:
npm i use-debounce
<Search>
コンポーネントで、useDebouncedCallback
という関数をインポートします:
// ...
+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)が経過した場合にのみコードを実行します。
ここで、再び検索バーに入力し、開発者ツールでコンソールを開きます。次のように表示されるはずです:
Searching... Emil
デバウンスを使用することで、データベースに送信するリクエストの数を減らし、リソースを節約できます。
クイズの時間です!
知識をテストし、学んだことを確認しましょう。検索機能でデバウンスはどのような問題を解決しますか?
A. データベースクエリを高速化する
B. URLをブックマーク可能にする
C. キーストロークごとに新しいデータベースクエリが実行されるのを防ぐ
D. SEO最適化に役立つ
解答
C. キーストロークごとに新しいデータベースクエリが実行されるのを防ぐ
デバウンスすることで、キーを押すたびに新しいデータベースクエリが発生するのを防ぎ、リソースを節約できる。
ページネーションの追加
検索機能を導入した後、テーブルには一度に6つの請求書のみが表示されることに気づくでしょう。これは、data.ts
のfetchFilteredInvoices()
関数が、ページごとに最大6つの請求書を返すためです。
ページネーションを追加すると、ユーザーは異なるページを移動して、すべての請求書を表示できます。検索で行ったのと同じように、URLパラメータを使用してページネーションを実装する方法を見てみましょう。
<Pagination/>
コンポーネントに移動すると、それがクライアントコンポーネントであることがわかります。データベースのシークレットが公開されてしまうため、クライアント上でデータをフェッチしたくありません(APIレイヤーを使用していないことを思い出してください)。代わりに、サーバー上でデータをフェッチし、それをpropとしてコンポーネントに渡すことができます。
/dashboard/invoices/page.tsx
で、fetchInvoicesPages
という新しい関数をインポートし、searchParams
からquery
を引数として渡します:
// ...
+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になります。
次に、totalPages
propを<Pagination/>
コンポーネントに渡します:
// ...
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/>
コンポーネントに移動し、usePathname
とuseSearchParams
フックをインポートします。これを使用して、現在のページを取得し、新しいページを設定します。また、このコンポーネントのコードのコメントを外してください。<Pagination/>
のロジックをまだ実装していないので、アプリケーションは一時的に壊れます。それでは、実装しましょう!
'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文字列を作成します。
'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
関数を更新することで実行できます:
'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の検索パラメータを使用し、この状態をサーバーに移行することの利点を理解できたことでしょう。
次の章
Discussion