これはNext.jsの公式チュートリアルの11. Adding Search and Pagination に関するメモです
前章のメモ
Next.jsの公式チュートリアルの該当ページ
学ぶこと
- useSearchParams, usePathname, useRouter の使い方
- クエリパラメータを使用した検索とページネーションの実装
下準備
/app/dashboard/invoices/page.tsx を下記のコードにまるっと書き換えます
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
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を設定し、入力が変わり次第関数を実行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
'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にアクセスしてみます
ブラウザで開発者ツールを開き(F12)、コンソールを表示した上で検索窓に文字を入力
クエリパラメータを使用したURLに更新
ここからは検索窓からの入力をクエリパラメータとしたURLに更新していきます
処理の内容としては
- useSearchParams (Client ComponentのHook) で現在のクエリパラメータを取得
- クエリパラメータを操作するために URLSearchParams を1. でインスタンス化
- 検索窓から取得した文字列によってクエリパラメータを操作
- 文字列があれば、それでクエリパラメータを上書き
- 〃 がなければ、クエリパラメータを削除
- 3. で生成したクエリパラメータを用いてuseRouterを使ってURLを更新
- 今回使用するrouter.replace() だとブラウザの履歴を上書き
- ちなみにrouter.push()だとブラウザの履歴を追加
useSearchParams (Client ComponentのHook) で現在のクエリパラメータを取得
1 2 3 4 5 6 7 8 9 10 11 12 13 |
'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. でインスタンス化
1 2 3 4 5 6 7 8 9 10 11 12 13 |
'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); } // 略 |
検索窓から取得した文字列によってクエリパラメータを操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
'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が更新される
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
'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()}`); } // 略 |
http://localhost:3000/dashboard/invoices にアクセスして検索窓に文字を入力するとURLも更新されていることが確認できます
URLと検索窓を同期
検索窓に文字を入力してURLが更新されるようになりましたが、その逆はまだです。
つまり、URLで指定されたクエリパラメータを検索窓に反映させるということ。
なので、例えば http://localhost:3000/dashboard/invoices?query=search にアクセスしても
検索窓に何も文字列がありません
<input> の defaultValue にURLから取得したクエリパラメータを指定すればOK
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
'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:クエリパラメータ(今回はこちらを使用)
/app/dashboard/invoices/page.tsx の引数で受け取って<Table>に渡しコメントアウトを外す
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
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してます
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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というライブラリを使用
まずはインストール。
1 |
npm i use-debounce |
useDebouncedCallbackをインポートしてhandleSearch()をラップ。
第2引数に待機時間(ms)を指定
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
'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のデータしか取得していないことがわかります
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
// 一部抜粋 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> でパスやクエリパラメータを取得
- <Pagination> でページネーションにて使用するリンクを作成
- <Search> で検索ワードが入力されたらページを先頭へ
検索ワードにヒットするデータ数からトータルのページ数を取得
<Pagination>はクライアントコンポーネント。
なので、ServerComponentでfetchInvoicesPages()を実行して結果を渡す。
また、1ページあたり6件のデータを表示。
例えば、12件ヒットしたら、fetchInvoicesPages()は2を返すようになってます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
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 を使います
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
'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属性をご覧ください)
ついでにコメントアウトも外しておきます
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
'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をセットして検索後は最初のページを表示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
'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を使用した画面遷移
次章のメモ
コメント