🔥

【11章】Next.jsのチュートリアルをやってみた

2025/01/12に公開

前章のメモ

https://zenn.dev/kuuki/articles/nextjs-tutorial-10/

Next.js の公式チュートリアルの該当ページ

https://nextjs.org/learn/dashboard-app/adding-search-and-pagination

学ぶこと

  • useSearchParams, usePathname, useRouterの使い方
  • クエリパラメータを使用した検索とページネーションの実装

下準備

/app/dashboard/invoices/page.tsxを下記のコードにまるっと書き換えます

/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>
  );
}

使用しているコンポーネントを見ていくと

  1. <Search/>:検索窓
  2. <Pagination/>:ページネーション
  3. <Table/>:請求書データを表示する表

検索処理の挙動としては

  1. ユーザが請求書を検索
  2. クエリパラメータを更新
  3. サーバでデータを取得
  4. 取得したデータをもとにサーバ上で請求書の表を再レンダリング

クエリパラメータを使う検索のメリットは??

  1. URL をブックマークに登録したり、共有できる
    • 検索キーワードによって URL が異なる
    • 検索後の URL に後からアクセスしたり、他の人に共有できる
  2. サーバ側レンダリングと初期ロード
    • クエリパラメータをもとにサーバ側で初期状態からレンダリングできる
    • ので、サーバレンダリングの処理が簡単になる
  3. 分析&追跡
    • 検索時のクエリやフィルターを URL に含めることでユーザの行動が追跡しやすい

検索機能の実装

検索機能の実装に必要な Next.js のフックは

  1. useSearchParams
    • クエリパラメータを取得する
    • /dashboard/invoices?page=1&query=pending なら、 {page: '1', query: 'pending'}
  2. usePathname
    • URL のパスを取得する
    • /dashboard/invoices?page=1&query=pending なら、'/dashboard/invoices'
  3. useRouter
    • コード上でページ間のナビゲーションできる
    • 公式ドキュメント
    • 特定のページへの移動やリロード、前のページへ戻るなどが可能

実装の流れとしては

  1. ユーザからの入力を受け取る
  2. クエリパラメータを使用した URL に更新
  3. URL と検索窓を同期
  4. 表の内容を更新

ユーザからの入力を受け取る

検索窓となる<Search> (/app/ui/search.tsx)を見てみます

特徴としては

  • 'use client';Client Componentへ ⇒ イベントリスナーや Hooks が使える
  • <input>:検索する文字列を受け取る

/app/ui/search.tsxで<input>に文字列が入力されたときの挙動を見てみます

  1. 引数をコンソールに出力する handleSearch 関数を作成
  2. input の onChange に handleSearch を設定し、入力が変わり次第関数を実行
/app/ui/search.tsx
'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 に更新していきます

処理の内容としては

  1. useSearchParams (Client Component の Hook) で現在のクエリパラメータを取得
  2. クエリパラメータを操作するために URLSearchParams を 1. でインスタンス化
  3. 検索窓から取得した文字列によってクエリパラメータを操作
    1. 文字列があれば、それでクエリパラメータを上書き
    2. 〃 がなければ、クエリパラメータを削除
  4. 3. で生成したクエリパラメータを用いてuseRouterを使って URL を更新
    1. 今回使用するrouter.replace()だとブラウザの履歴を上書き
    2. ちなみにrouter.push()だとブラウザの履歴を追加

useSearchParams (Client Component の Hook) で現在のクエリパラメータを取得

/app/ui.search.tsx
'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. でインスタンス化

/app/ui.search.tsx
'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);
  }

// 略

検索窓から取得した文字列によってクエリパラメータを操作

/app/ui.search.tsx
'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 を更新

  1. usePathname()現在のパスを取得
    1. 今回の場合は、'/dashboard/invoices' を取得
  2. params に格納されているクエリパラメータをtoString()で URL に使える表記に変換
  3. router.replace() でパスとクエリパラメータを更新
  4. クライアント側のナビゲーションなのでページのリロードなしで URL が更新される
/app/ui.search.tsx
'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

/app/ui.search.tsx
'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種類で

  1. params:dynamic route において URL から取得する値
  2. searchParams:クエリパラメータ(今回はこちらを使用)

クエリパラメータの取得方法 useSearchParamsクライアントコンポーネントで使う searchParamsサーバーコンポーネントで使う

/app/dashboard/invoices/page.tsxの引数で受け取って<Table>に渡しコメントアウトを外す

/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({
+ 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してます

/app/ui/invoices/table.tsx
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'と入力してみると、ヒットする結果のみ表示されます

デバウンス

デバウンス関数が実行される頻度を制御すること

デバウンスの流れ

  1. トリガーイベント:イベントが発生するとタイマーを開始
  2. 待機:指定した時間が経過するまで待つ。新しいイベントが発生したらリセット
  3. 実行:時間が経過するとデバウンスした関数を実行

今回の検索機能だと文字が入力されるたびに DB からデータを取得しています

ユーザが多くなるほど DB への負荷も高まりパフォーマンスが低下 ↓

なので、ユーザが入力を停止したタイミングで DB にデータを取りに行きたい

⇒  handleSearch() をデバウンス。

デバウンスするにはいくつか方法がありますが、今回はuse-debounceというライブラリを使用

まずはインストール。

npm i use-debounce

useDebouncedCallbackをインポートしてhandleSearch() をラップ。

第2引数に待機時間(ms)を指定

/app/ui.search.tsx
'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 のデータしか取得していないことがわかります

/app/dashboard/invoices/page.tsx
// 一部抜粋

+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 件以上データがヒットする場合もあるのでページネーションを実装します。

検索機能と同じくクエリパラメータを使用

実装の流れ

  1. 検索ワードにヒットするデータ数からトータルのページ数を取得
    1. <Pagination>はクライアントコンポーネントなので ServerComponent で fetchInvoicesPages()を実行して結果を渡す。
    2. 1 ページあたり 6 件のデータを表示する
      1. 例えば、12 件ヒットしたら、fetchInvoicesPages()は 2 を返す
  2. <Pagination> でパスやクエリパラメータを取得
  3. <Pagination> でページネーションにて使用するリンクを作成
  4. <Search> で検索ワードが入力されたらページを先頭へ

検索ワードにヒットするデータ数からトータルのページ数を取得

<Pagination>はクライアントコンポーネント。

なので、ServerComponent で fetchInvoicesPages()を実行して結果を渡す。

また、1 ページあたり 6 件のデータを表示。

例えば、12 件ヒットしたら、fetchInvoicesPages()は 2 を返すようになってます。

/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';
+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> でパスやクエリパラメータを取得

検索機能と同じくusePathnameuseSearchParamsを使います

/app/ui/invoices/pagination.tsx
'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 になる

(同ファイルのPaginationNumberPaginationArrowの href 属性をご覧ください)

PaginationNumber:ページネーションの数字の部分
PaginationArrow:両端の矢印の部分また、表示する数字のテキストは /app/lib/util.tsgeneratePaginationで定義

ついでにコメントアウトも外しておきます

'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 をセットして検索後は最初のページを表示

/app/ui/search.tsx
'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