Next.jsのチュートリアルをやってみる【検索とページネーション編】

前回

Next.jsのチュートリアルをやってみる【部分プリレンダリング編】

はじめに

前回はストリーミングを利用しダッシュボードの読み込みのパフォーマンスを向上させました。

今回は/invoicesページに検索とページネーションを追加する方法を学んでいきます。

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

このコードではこんなことをしています。

コンポーネント内容
<Search/>特定の請求書を検索する。
<Pagination/>請求書のページ間を移動する。
<Table/>請求書を表示する。
各コンポーネントの機能

検索機能はクライアントとサーバーにまたがります。ユーザーがクライアントで請求書を検索するとURLのパラメーターが更新、サーバーでデータが取得され、新しいデータでテーブルがサーバーで再レンダリングされます。

なぜ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クライアント・コンポーネント内のルート間のナビゲーションをプログラムで可能にします。使用できるメソッドは複数あります。
クライアントフックの説明

検索機能は以下の順序で実装を進めていきます。

  1. ユーザーの入力をキャプチャする。
  2. URLを検索パラメーターで更新する。
  3. URLを入力フィールドと同期させる。
  4. 検索クエリを反映させるためにテーブルを更新する。

ユーザーの入力をキャプチャする

/app/ui/search.tsxにある<Search>コンポーネントは、クライアント・コンポーネントであることが確認できるため、イベントリスナーやフックを使用することができることができます。

新しくhandleSeach関数を作成し、<input>要素にonChangeリスナーを追加しましょう。inputonChangeは入力値が変わるたびにhandleSeachを呼び出すようにします。

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

デベロッパーツールでコンソールを開いて検索フィールドに文字を入力してみましょう。正しく動作している場合は変化するたびにログが出力されるようになっているはずです。

検索パラメータでURLを更新する

next/navigationからuseSearchParamsフックをインポートし、seachParams変数に代入します。その後handleSeachの中でseachParams変数を使用してURLSeachParamsインスタンスを作成します。

/app/ui/search.tsx
/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を削除するようにします。

/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);
    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
  }
  // ...
}

これでクエリの文字列ができました。Next.jsのuseRouterusePathnameフックを使用してURLを更新することができます。useRouterusePathNamenext/navigationからインポートし、handleSearch内でuseRouter()replaceメソッドを使用します。

/app/ui/search.tsx
'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から読み込んでinputdefaultValueを渡します。

/app/ui/search.tsx
<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>コンポーネントに渡すことができます。

/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 { 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>コンポーネントに移動すると、querycurrentPageの二つのpropsがfetchFilteredInvoices()関数に渡され、クエリに一致する請求書を返すことが分かります。

最後にテストしてみましょう。入力フォームに文字列を入力するとURLが更新されサーバーに新しいリクエストが送信されます。その後サーバー上でデータが取得され、クエリに一致する請求書のみが返されます。

useSearchParams()フックとsearchParamspropの使い分け

検索パラメーターを抽出するためにはフックとpropの二つの方法がありました。一般的にはクライアントからパラメータを読み込みたい場合はフックを使用します。

  • <Search>はクライアントコンポーネントなので、クライアントからパラメータにアクセスするためにフックを使用しました。
  • <Table>はそれ自身のデータをフェッチするサーバーコンポーネントなので、ページからコンポーネントにsearchParamspropを渡すことができます。

Debouncingの追加

現在はキー入力するたびにURLを更新しているので、キー入力するたびにデータベースに問い合わせを行っています。このままではアプリケーションがの規模が大きくなるとリクエストの数が膨大になってしまいます。

Debouncingは関数が実行できるレートを制限するプログラミング手法です。今回のケースではユーザーが入力をやめたときにだけデータベースに問い合わせを行うように変更していきましょう。

独自にDebouncingに関する関数を実装することもできますが、今回はシンプルにするためにuse-debounceというライブラリを使用します。

まずはターミナルを開いてpnpm i use-debounceと入力し、ライブラリをインストールします。

<Search>コンポーネントでuseDebouncedCallback関数をインポートして使用します。handleSearchの内容をラップし、ユーザーが入力を止めてから300ms後にのみコードを実行するように変更します。

/app/ui/search.tsx
// ...
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.tsfetchFilteredInvoices()関数が1ページに最大6件の請求書を返すためです。

ページを分割することで、ユーザーは様々なページをナビゲートしてすべての請求書を見ることができます。検索で行ったように、URLパラメータを使用してページ分割を実装する方法を見てみましょう。

<Pagination/>コンポーネントはクライアント・コンポーネントです。クライアントでデータを取得するということはデータベースの秘密を公開することになるので避けたいと感じるはずです。サーバーでデータを取得し、それをpropとしてコンポーネントに渡すように変更していきましょう。

まずは/dashboard/invoices/page.tsxfetchInvoicesPagesという新しい関数をインポートし、searchParamsからクエリを引数として渡します。

/app/dashboard/invoices/page.tsx
// ...
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/>コンポーネントに移動し、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>コンポーネントの中にcreatePageURLという新しい関数を作成します。この関数は検索と同様に、URLSearchParamsを使用して、新しいページ番号を設定し、pathNameを使用してURL文字列を作成します。

/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;
 
  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にリセットするように変更しましょう。

/app/ui/search.tsx
'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フックを使用することで、クライアント側でのトランジションがよりスムーズになります

次回

Next.jsのチュートリアルをやってみる【データの変異編】

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です