【11章】Next.jsのチュートリアルをやってみた
前章のメモ
Next.js の公式チュートリアルの該当ページ
学ぶこと
- useSearchParams, usePathname, useRouterの使い方
- クエリパラメータを使用した検索とページネーションの実装
下準備
/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 に含めることでユーザの行動が追跡しやすい
検索機能の実装
検索機能の実装に必要な Next.js のフックは
-
useSearchParams
- クエリパラメータを取得する
- /dashboard/invoices?page=1&query=pending なら、 {page: '1', query: 'pending'}
-
usePathname
- URL のパスを取得する
- /dashboard/invoices?page=1&query=pending なら、'/dashboard/invoices'
-
useRouter
- コード上でページ間のナビゲーションできる
- 公式ドキュメント
- 特定のページへの移動やリロード、前のページへ戻るなどが可能
実装の流れとしては
- ユーザからの入力を受け取る
- クエリパラメータを使用した URL に更新
- URL と検索窓を同期
- 表の内容を更新
ユーザからの入力を受け取る
検索窓となる<Search> (/app/ui/search.tsx)を見てみます
特徴としては
- 'use client';でClient Componentへ ⇒ イベントリスナーや Hooks が使える
- <input>:検索する文字列を受け取る
/app/ui/search.tsxで<input>に文字列が入力されたときの挙動を見てみます
- 引数をコンソールに出力する handleSearch 関数を作成
- input の 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>
);
}
これでローカルサーバを起動(npm run dev) して、下記 URL にアクセスしてみます
http://localhost:3000/dashboard/invoices
ブラウザで開発者ツールを開き(F12)、コンソールを表示した上で検索窓に文字を入力
クエリパラメータを使用した URL に更新
ここからは検索窓からの入力をクエリパラメータとした URL に更新していきます
処理の内容としては
- useSearchParams (Client Component の Hook) で現在のクエリパラメータを取得
- クエリパラメータを操作するために URLSearchParams を 1. でインスタンス化
- 検索窓から取得した文字列によってクエリパラメータを操作
- 文字列があれば、それでクエリパラメータを上書き
- 〃 がなければ、クエリパラメータを削除
- 3. で生成したクエリパラメータを用いてuseRouterを使って URL を更新
- 今回使用するrouter.replace()だとブラウザの履歴を上書き
- ちなみにrouter.push()だとブラウザの履歴を追加
useSearchParams (Client Component の Hook) で現在のクエリパラメータを取得
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
+import { useSearchParams } from 'next/navigation';
export default function Search({ placeholder }: { placeholder: string }) {
+ const searchParams = useSearchParams();
function handleSearch(term:string) {
console.log(term);
}
// 略
クエリパラメータを操作するために URLSearchParams を 1. でインスタンス化
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';
export default function Search({ placeholder }: { placeholder: string }) {
const searchParams = useSearchParams();
function handleSearch(term:string) {
+ const params = new URLSearchParams(searchParams);
}
// 略
検索窓から取得した文字列によってクエリパラメータを操作
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';
export default function Search({ placeholder }: { placeholder: string }) {
const searchParams = useSearchParams();
function handleSearch(term:string) {
const params = new URLSearchParams(searchParams);
+ if (term) {
+ params.set('query', term);
+ } else {
+ params.delete('query');
+ }
}
// 略
生成したクエリパラメータを用いて useRouter を使って URL を更新
-
usePathname() で現在のパスを取得
- 今回の場合は、'/dashboard/invoices' を取得
- params に格納されているクエリパラメータをtoString()で URL に使える表記に変換
- router.replace() でパスとクエリパラメータを更新
- クライアント側のナビゲーションなのでページのリロードなしで URL が更新される
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
+import { useSearchParams, usePathname, useRouter } from 'next/navigation';
export default function Search({ placeholder }: { placeholder: string }) {
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()}`);
}
// 略
const params = new URLSearchParams(searchParams);
で searchParams が ReadonlyURLSearchParams のためエディター上でエラーになっている可能性ありです。
そのままにしておくのは少しうーんという感じですが、 今回は**チュートリアルをやることがもくてきなのであえてスルーします
http://localhost:3000/dashboard/invoices にアクセスして検索窓に文字を入力すると URL も更新されていることが確認できます
URL と検索窓を同期
検索窓に文字を入力して URL が更新されるようになりましたが、その逆はまだです。
つまり、URL で指定されたクエリパラメータを検索窓に反映させるということ。
なので、例えば http://localhost:3000/dashboard/invoices?query=search にアクセスしても
検索窓に何も文字列がありません
<input> の defaultValue に URL から取得したクエリパラメータを指定すれば OK
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
export default function Search({ placeholder }: { placeholder: string }) {
// 略
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);
}}
+ defaultValue={searchParams.get('query')?.toString()}
/>
<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>
);
}
http://localhost:3000/dashboard/invoices?query=search にアクセスしてみると
検索窓に'search'が表示されてます
検索結果を表示
検索ワードによって URL は更新したので、あとは検索結果を表示します
page.tsx の Page コンポーネントでは引数からクエリパラメータ等を取得できます
使用できる引数は2種類で
- params:dynamic route において URL から取得する値
- searchParams:クエリパラメータ(今回はこちらを使用)
クエリパラメータの取得方法 useSearchParams:クライアントコンポーネントで使う searchParams:サーバーコンポーネントで使う
/app/dashboard/invoices/page.tsxの引数で受け取って<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 { InvoicesTableSkeleton } from '@/app/ui/skeletons';
import { Suspense } from 'react';
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>コンポーネントを見ると、受け取ったクエリとページをもとにデータをfetchしてます
import Image from 'next/image';
import { UpdateInvoice, DeleteInvoice } from '@/app/ui/invoices/buttons';
import InvoiceStatus from '@/app/ui/invoices/status';
import { formatDateToLocal, formatCurrency } from '@/app/lib/utils';
import { fetchFilteredInvoices } from '@/app/lib/data';
export default async function InvoicesTable({
query,
currentPage,
}: {
query: string;
currentPage: number;
}) {
+ const invoices = await fetchFilteredInvoices(query, currentPage);
return (
// 略
これでページにアクセスするとテーブルが表示されます
検索窓に'lee'と入力してみると、ヒットする結果のみ表示されます
デバウンス
デバウンス=関数が実行される頻度を制御すること
デバウンスの流れ
- トリガーイベント:イベントが発生するとタイマーを開始
- 待機:指定した時間が経過するまで待つ。新しいイベントが発生したらリセット
- 実行:時間が経過するとデバウンスした関数を実行
今回の検索機能だと文字が入力されるたびに DB からデータを取得しています
ユーザが多くなるほど DB への負荷も高まりパフォーマンスが低下 ↓
なので、ユーザが入力を停止したタイミングで DB にデータを取りに行きたい
⇒ handleSearch() をデバウンス。
デバウンスするにはいくつか方法がありますが、今回はuse-debounceというライブラリを使用
まずはインストール。
npm i use-debounce
useDebouncedCallbackをインポートしてhandleSearch() をラップ。
第2引数に待機時間(ms)を指定
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
+import { useDebouncedCallback } from 'use-debounce';
export default function Search({ placeholder }: { placeholder: string }) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
+ 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);
// 略
これでページにアクセスして開発者ツール(F12)でコンソールを見ます。
検索窓に文字を入力すると、入力する度ではなく入力が停止したタイミングで表示されます
ページネーションの実装
さて、/app/dashboard/invoices/page.tsx の fetchFilteredInvoices() を見ると最大 6 のデータしか取得していないことがわかります
// 一部抜粋
+const ITEMS_PER_PAGE = 6;
export async function fetchFilteredInvoices(
query: string,
currentPage: number,
) {
noStore();
+ const offset = (currentPage - 1) * ITEMS_PER_PAGE;
try {
const invoices = await sql<InvoicesTable>`
SELECT
invoices.id,
invoices.amount,
invoices.date,
invoices.status,
customers.name,
customers.email,
customers.image_url
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
WHERE
customers.name ILIKE ${`%${query}%`} OR
customers.email ILIKE ${`%${query}%`} OR
invoices.amount::text ILIKE ${`%${query}%`} OR
invoices.date::text ILIKE ${`%${query}%`} OR
invoices.status ILIKE ${`%${query}%`}
ORDER BY invoices.date DESC
+ LIMIT ${ITEMS_PER_PAGE} OFFSET ${offset}
`;
return invoices.rows;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch invoices.');
}
}
6 件以上データがヒットする場合もあるのでページネーションを実装します。
検索機能と同じくクエリパラメータを使用。
実装の流れ
- 検索ワードにヒットするデータ数からトータルのページ数を取得
- <Pagination>はクライアントコンポーネントなので ServerComponent で fetchInvoicesPages()を実行して結果を渡す。
-
1 ページあたり 6 件のデータを表示する
- 例えば、12 件ヒットしたら、fetchInvoicesPages()は 2 を返す
- <Pagination> でパスやクエリパラメータを取得
- <Pagination> でページネーションにて使用するリンクを作成
- <Search> で検索ワードが入力されたらページを先頭へ
検索ワードにヒットするデータ数からトータルのページ数を取得
<Pagination>はクライアントコンポーネント。
なので、ServerComponent で fetchInvoicesPages()を実行して結果を渡す。
また、1 ページあたり 6 件のデータを表示。
例えば、12 件ヒットしたら、fetchInvoicesPages()は 2 を返すようになってます。
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';
+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>
);
}
<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> でページネーションにて使用する URL を作成
取得したクエリパラメータの page を引数で URL を更新して返す。
この関数で生成された URL がページネーションで使用する URL になる
(同ファイルのPaginationNumberやPaginationArrowの href 属性をご覧ください)
PaginationNumber:ページネーションの数字の部分
PaginationArrow:両端の矢印の部分また、表示する数字のテキストは /app/lib/util.tsの generatePaginationで定義
ついでにコメントアウトも外しておきます
'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()}`;
};
// NOTE: comment in this code when you get to this point in the course
const allPages = generatePagination(currentPage, totalPages);
return (
<>
{/* NOTE: comment in this code when you get to this point in the course */}
<div className="inline-flex">
<PaginationArrow
direction="left"
href={createPageURL(currentPage - 1)}
isDisabled={currentPage <= 1}
/>
<div className="flex -space-x-px">
{allPages.map((page, index) => {
let position: 'first' | 'last' | 'single' | 'middle' | undefined;
if (index === 0) position = 'first';
if (index === allPages.length - 1) position = 'last';
if (allPages.length === 1) position = 'single';
if (page === '...') position = 'middle';
return (
<PaginationNumber
key={page}
href={createPageURL(page)}
page={page}
position={position}
isActive={currentPage === page}
/>
);
})}
</div>
<PaginationArrow
direction="right"
href={createPageURL(currentPage + 1)}
isDisabled={currentPage >= totalPages}
/>
</div>
</>
);
}
// 略
<Search> で検索ワードが入力されたらページを先頭へ
クエリパラメータのpage に 1 をセットして検索後は最初のページを表示
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';
export default function Search({ placeholder }: { placeholder: string }) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
const handleSearch = useDebouncedCallback((term) => {
console.log(`Searching... ${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 を使用した画面遷移
次章のメモ
coming soon...
Discussion