前のチャプターでは、ストリーミングを使用してダッシュボードの初期読み込みパフォーマンスを向上させました。それでは、/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に直接含まれているため、追加のクライアント側ロジックを必要とせずにユーザーの行動を追跡しやすくなります。
検索機能を実装するために使用する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を入力フィールドと同期させる。
- 検索クエリを反映するようにテーブルを更新する。
<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を更新する必要があります。
handleSearch
内で、新しい URLSearchParams
インスタンスを新しい searchParams
変数を使用して作成します。
'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が更新されます(ページ間の移動の章で学習しました)。
defaultValue
対 value
/ 制御対非制御
状態を使用して入力の値を管理している場合は、value
属性を使用して制御コンポーネントにします。これは、Reactが入力の状態を管理することを意味します。
ただし、状態を使用していないため、defaultValue
を使用できます。これは、ネイティブ入力が自身の状態を管理することを意味します。検索クエリを状態ではなくURLに保存しているため、これは問題ありません。
最後に、検索クエリを反映するようにテーブルコンポーネントを更新する必要があります。
請求書ページに戻ります。
ページコンポーネントは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
をインストールする
<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)後にのみコードを実行します。
もう一度検索バーに入力し、開発ツールでコンソールを開いてください。以下が表示されます。
デバウンシングにより、データベースに送信されるリクエストの数を減らし、リソースを節約できます。
検索機能を導入した後、テーブルには一度に6つの請求書しか表示されないことに気付くでしょう。これは、data.ts
のfetchFilteredInvoices()
関数が、ページごとに最大6つの請求書を返すためです。
ページネーションを追加することで、ユーザーは異なるページを移動してすべての請求書を表示できます。検索の場合と同様に、URLパラメータを使用してページネーションを実装する方法を見てみましょう。
<Pagination/>
コンポーネントに移動すると、それがクライアントコンポーネントであることがわかります。クライアント側でデータを取得すると、データベースの機密情報が公開されるため、これは避けたいです(APIレイヤーを使用していないことを忘れないでください)。代わりに、サーバー側でデータを取得し、それをpropsとしてコンポーネントに渡すことができます。
/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
propを<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検索パラメータを使用し、この状態をサーバーにリフトすることの利点を理解していただければ幸いです。