前回
Next.jsのチュートリアルをやってみる【部分プリレンダリング編】はじめに
前回はストリーミングを利用しダッシュボードの読み込みのパフォーマンスを向上させました。
今回は/invoices
ページに検索とページネーションを追加する方法を学んでいきます。
/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 というルートに対しては/dashboard/invoices を返します。 |
useRouter | クライアント・コンポーネント内のルート間のナビゲーションをプログラムで可能にします。使用できるメソッドは複数あります。 |
検索機能は以下の順序で実装を進めていきます。
- ユーザーの入力をキャプチャする。
- URLを検索パラメーターで更新する。
- URLを入力フィールドと同期させる。
- 検索クエリを反映させるためにテーブルを更新する。
ユーザーの入力をキャプチャする
/app/ui/search.tsx
にある<Search>
コンポーネントは、クライアント・コンポーネントであることが確認できるため、イベントリスナーやフックを使用することができることができます。
新しくhandleSeach
関数を作成し、<input>
要素にonChange
リスナーを追加しましょう。input
のonChange
は入力値が変わるたびにhandleSeach
を呼び出すようにします。
'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を更新する
next/navigation
からuseSearchParams
フックをインポートし、seachParams
変数に代入します。その後handleSeach
の中でseachParams
変数を使用してURLSeachParams
インスタンスを作成します。
/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
のようなパラメーター文字列を取得するために使用できます。
また、ユーザーの入力に基づいてparams
文字列を設定しましょう。入力が空の場合はparams
を削除するようにします。
'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を更新することができます。useRouter
とusePathName
をnext/navigation
からインポートし、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はページをリロードすることなく更新されます。
URLと入力を同期させる
入力フィールドがURLと同期し、共有したURLからアクセスされた時に自動入力されるようにするには、searchParams
から読み込んでinput
に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()}
/>
テーブルの更新
最後に、検索クエリを反映するためにテーブルコンポーネントを更新する必要があります。
請求書ページの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
の二つのpropsがfetchFilteredInvoices()
関数に渡され、クエリに一致する請求書を返すことが分かります。
最後にテストしてみましょう。入力フォームに文字列を入力するとURLが更新されサーバーに新しいリクエストが送信されます。その後サーバー上でデータが取得され、クエリに一致する請求書のみが返されます。
useSearchParams()
フックとsearchParams
propの使い分け
検索パラメーターを抽出するためにはフックとpropの二つの方法がありました。一般的にはクライアントからパラメータを読み込みたい場合はフックを使用します。
<Search>
はクライアントコンポーネントなので、クライアントからパラメータにアクセスするためにフックを使用しました。<Table>
はそれ自身のデータをフェッチするサーバーコンポーネントなので、ページからコンポーネントにsearchParams
propを渡すことができます。
Debouncingの追加
現在はキー入力するたびにURLを更新しているので、キー入力するたびにデータベースに問い合わせを行っています。このままではアプリケーションがの規模が大きくなるとリクエストの数が膨大になってしまいます。
Debouncingは関数が実行できるレートを制限するプログラミング手法です。今回のケースではユーザーが入力をやめたときにだけデータベースに問い合わせを行うように変更していきましょう。
独自にDebouncingに関する関数を実装することもできますが、今回はシンプルにするためにuse-debounce
というライブラリを使用します。
まずはターミナルを開いてpnpm i use-debounce
と入力し、ライブラリをインストールします。
<Search>
コンポーネントでuseDebouncedCallback
関数をインポートして使用します。handleSearch
の内容をラップし、ユーザーが入力を止めてから300ms後にのみコードを実行するように変更します。
// ...
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);
ページネーションの追加
現在はテーブルが表示されるようになりましたが、6件までしか表示されません。これはdata.ts
のfetchFilteredInvoices()
関数が1ページに最大6件の請求書を返すためです。
ページを分割することで、ユーザーは様々なページをナビゲートしてすべての請求書を見ることができます。検索で行ったように、URLパラメータを使用してページ分割を実装する方法を見てみましょう。
<Pagination/>
コンポーネントはクライアント・コンポーネントです。クライアントでデータを取得するということはデータベースの秘密を公開することになるので避けたいと感じるはずです。サーバーでデータを取得し、それをpropとしてコンポーネントに渡すように変更していきましょう。
まずは/dashboard/invoices/page.tsx
でfetchInvoicesPages
という新しい関数をインポートし、searchParams
からクエリを引数として渡します。
// ...
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 (
<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>
);
}
fetchInvoicesPages
は検索クエリに基づくページの総数を返します。例えば請求書が12件あり、各ページに6件の請求書が表示される場合は2ページとなります。
<Pagination/>
コンポーネントに移動し、usePathname
とuseSearchParams
フックをインポートして、現在のページと新しいページを設定できるようにします。コメントアウトされているのでコメントを外しましょう。
'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にリセットするように変更しましょう。
'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の検索パラメーターを使用して処理している
- サーバーでデータを取得する
useRouter
フックを使用することで、クライアント側でのトランジションがよりスムーズになります