11
チャプター11
検索とページネーションの追加
前チャプターでは、ストリーミングを使ってダッシュボードの初期読み込みパフォーマンスを改善しました。今回は/invoices
ページに移り、検索とページネーションの追加方法を学びましょう。
このチャプターの内容
取り上げるトピックはこちら
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: 検索パラメーターがURLに含まれるため、ユーザーは検索クエリやフィルターを含むアプリケーションの現在の状態をブックマークして、後で参照したり共有したりできます。
- サーバーサイドレンダリング: URLパラメーターはサーバーで直接消費して初期状態をレンダリングできるため、サーバーレンダリングの処理が容易になります。
- 分析と追跡: 検索クエリとフィルターがURLに直接含まれるため、追加のクライアントサイドロジックを必要とせずにユーザーの行動を追跡しやすくなります。
検索機能の追加
検索機能を実装するために使用するNext.jsのクライアントフックは以下の通りです。
useSearchParams
- 現在のURLのパラメーターにアクセスできます。たとえば、URL/dashboard/invoices?page=1&query=pending
の検索パラメーターは、{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
となります。- URLは、Next.jsのクライアントサイドナビゲーション(これについてはページ間ナビゲーションのチャプターで学びました)のおかげで、ページを再読み込みすることなく更新されます。
3. URLと入力の同期を保つ
入力フィールドがURLと同期し、共有時に自動入力されるようにするには、searchParams
から読み取ったdefaultValue
を入力に渡すことができます。
<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>
コンポーネントに渡すことができます。
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
の2つのプロパティがfetchFilteredInvoices()
関数に渡され、クエリに一致する請求書が返されることがわかります。
// ...
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
を追加してください。
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を更新しており、したがってキーストロークごとにデータベースにクエリを発行しているのです!私たちのアプリケーションは小規模なので問題ありませんが、もしあなたのアプリケーションに何千ものユーザーがいて、それぞれがキーストロークごとに新しいリクエストをデータベースに送信していると想像してみてください。
デバウンスは、関数が発火する速度を制限するプログラミング手法です。この場合、ユーザーがタイピングを停止したときにのみデータベースにクエリを発行したいと考えます。
デバウンスの仕組み
- トリガーイベント: デバウンスすべきイベント(検索ボックスでのキーストロークなど)が発生すると、タイマーが開始されます。
- 待機: タイマーが期限切れになる前に新しいイベントが発生した場合、タイマーはリセットされます。
- 実行: タイマーがカウントダウンを終えると、デバウンスされた関数が実行されます。
デバウンスを実装する方法はいくつかあり、独自のデバウンス関数を手動で作成することもできます。簡単に済ませるために、use-debounce
というライブラリを使用します。
use-debounce
をインストール
pnpm i use-debounce
<Search>
コンポーネントで、useDebouncedCallback
という関数をインポートします。
// ...
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
の内容をラップし、ユーザーがタイピングを停止した後、特定の時間(300ms)が経過してからのみコードを実行します。
もう一度検索バーに入力し、開発者ツールのコンソールを開いてください。以下の内容が表示されるはずです。
Searching... Delba
デバウンスを行うことで、データベースへのリクエスト数を減らし、リソースを節約できます。
ページネーションの追加
検索機能を導入した後、テーブルには一度に6件の請求書しか表示されないことに気づくでしょう。これは、data.ts
のfetchFilteredInvoices()
関数が1ページあたり最大6件の請求書を返すためです。
ページネーションを追加すると、ユーザーは異なるページ間を移動してすべての請求書を表示できます。検索の場合と同様に、URLパラメーターを使用してページネーションを実装する方法を見てみましょう。
<Pagination/>
コンポーネントに移動すると、それがクライアントコンポーネントであることがわかります。クライアントでデータをフェッチするとデータベースのシークレットが公開されるため(APIレイヤーを使用していないことを思い出してください)、これは望ましくありません。代わりに、サーバーでデータをフェッチし、それをプロパティとしてコンポーネントに渡すことができます。
/dashboard/invoices/page.tsx
で、fetchInvoicesPages
という新しい関数をインポートし、searchParams
からquery
を引数として渡します。
// ...
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/>
コンポーネントに渡します。
// ...
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/>
コンポーネントに移動し、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」パラメーターを提供されたページ番号に更新します。
- 最後に、パス名と更新された検索パラメーターを使用して完全な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検索パラメーターを使用し、この状態をサーバーに持ち上げる利点を、これでよりよく理解できたことを願います。
これは役に立ちましたか?