🦄

【Next.js】超簡単!! Next.jsで検索機能の実装方法

2024/08/03に公開

はじめに

Learn Next.jsで検索機能を復習したので、その振り返り記事を記載していきます。
https://nextjs.org/learn/dashboard-app/adding-search-and-pagination

今回実装する検索機能について

ユーザーがクライアントで食べ物を検索すると、URL パラメータが更新され、サーバー上でデータを取得、新しいデータを使用してサーバー上でテーブルが再レンダリングされる流れで検索機能を実装していきます。

Image from Gyazo

URL 検索パラメータを使用する利点

  1. ブックマークおよび共有可能なURL:
    検索パラメータをURLに含めることにより、ユーザーがそのURLをブックマークしたり、他のユーザーと簡単に共有したりすることができます。これにより、特定の検索結果やフィルター設定を簡単に再現できるようになります。

  2. サーバー側レンダリングと初期ロードの最適化:
    URLパラメータをサーバー側で処理することで、初期ページロード時にクライアントに対して完全にレンダリングされたページを提供することができます。これは特に、SEO(検索エンジン最適化)の面で重要であり、パフォーマンスの向上にもつながります。

  3. ユーザー体験の向上:
    URLに検索状態を反映させることで、ユーザーが複数のタブやウィンドウで異なる検索結果を同時に開いたり、特定の検索状態に簡単に戻ることができます。

  4. 状態管理の簡素化:
    クライアントサイドで状態を管理する代わりに、URLを状態の「Source of Truth」として使用することで、アプリケーションの状態管理が簡素化されます。URLが変更されると自動的にアプリケーションがその状態を反映するようになります。

検索機能で使用するNext.jsのclient hooks

  1. useSearchParams
    現在のURLのクエリパラメータにアクセスするために使用されます。検索やフィルタリングなどの機能を実装する際に非常に便利です。
    例:
    http://localhost:3000/food?page=1&query=pizza
    パラメーターは次のようになります。{page: '1', query: 'pizza'}

  2. usePathname
    現在のページのパス名(URLのパス部分)を取得するために使用されます。例えば、アプリケーションの特定のセクションで特定のUIを表示したい場合や、パスに基づいて特定のデータをロードする際に役立ちます。

  3. useRouter
    Next.jsのルーティングシステムにアクセスするために使用されます。これによりプログラムによるルートの変更や、現在のルートの情報(パス、クエリ、パラメータなど)にアクセスできます。ナビゲーションの制御や、ページ遷移のカスタマイズが可能になります。

1. ページの作成

app/foods/page.tsx
import Search from '@/app/ui/search';
import FoodsTable from '@/components/foods/table';

export default async function Page({
  searchParams,
}: {
  searchParams?: {
    query?: string;
    page?: string;
  };
}) {
  const query = searchParams?.query || '';

  return (
    <div className="w-full px-10">
      <div className="flex w-full items-center justify-between">
        <h1 className="text-2xl">Food Menu</h1>
      </div>
      <div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
        <Search placeholder="Search foods..." />
      </div>
      <FoodsTable query={query} />
    </div>
  );
}

2. 検索機能の作成

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: string) => {
    const params = new URLSearchParams(searchParams);
    params.set('page', '1');
    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
    replace(`${pathname}?${params.toString()}`);
  }, 300);

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

解説

  • ${pathname}は現在のパスです。例: /foods

  • ユーザーが検索バーに入力すると、params.toString()はこの入力をURLに適した形式に変換します。

  • replace(${pathname}?${params.toString()})は、ユーザーの検索データでURLを更新します。

  • Next.jsのクライアントサイドナビゲーションのおかげで、ページをリロードすることなくURLが更新されます。

  • 以下のコードは、ユーザーのアクションに基づいてURLを更新し、そのURLの状態に基づいてデータを取得または更新しています。

if (term) {
  params.set('query', term);
} else {
  params.delete('query');
}

1. URLパラメータの設定 (params.set('query', term);):
termに値がある場合、params.set('query', term);が実行されます。ここでURLSearchParamsのsetメソッドを使用して、URLのクエリパラメータqueryを更新または設定します。
例: ユーザーがappleと入力した場合、URLは?query=appleのようになります。

2. URLパラメータの削除 (params.delete('query');):
termが空(つまり何も入力されていないか、入力がクリアされた)の場合、params.delete('query');が実行されます。これにより、queryパラメータがURLから完全に削除されます。
これは、検索クエリをリセットし、すべてのデータを再度表示するための処理となります。

defaultValueについて

URLから query パラメータを取得し、それを input 要素の初期値として設定するために使用されています。defaultValue属性によって、コンポーネントが初めてレンダリングされる時に input の値が設定され、その後はユーザーによる入力によって値が更新されます。これにより、ページを再訪問した際に前回の検索状態を保持出来るようになります。

defaultValue={searchParams.get('query')?.toString()}

useSearchParamsとsearchParamsの使い分け

useSearchParamsの使用
useSearchParamsはクライアントサイドでURLの検索パラメータにアクセスするために使用されます。クライアントコンポーネント内で直接URLのクエリパラメータを読み取りや更新ができます。クライアントサイドの操作に特化しており、ページのリロードを引き起こさずにURLの状態を動的に管理できるため、ユーザーインタラクションが頻繁に発生する場合に適しています。

searchParamsの使用
一方で、searchParamsはサーバーサイドで使用されることが一般的です。サーバーコンポーネントがその初期レンダリング時にURLからクエリパラメータを受け取り、それを基にデータの取得や処理を行います。これにより、ページがサーバー上でレンダリングされる際に、URLのクエリに基づいた適切なデータを即座に取得して表示することが可能です。

【使用シナリオに基づく使い分け】
クライアントコンポーネントの場合: ユーザーの操作に応じてクエリパラメータを読み取りたい場合や、ページ遷移なしにクエリパラメータを更新したい場合には、useSearchParamsを使用します。これにより、クライアントサイドでのレスポンスが向上し、動的なURLの更新が容易になります。

サーバーコンポーネントの場合: ページの初期ロード時にサーバーからデータを取得する必要がある場合、searchParamsを用いてURLパラメータに基づくデータ取得を行います。これは特に、SEOを重視するページや、サーバーサイドレンダリングを利用したい場合に適しています。

Debounceについて

関数が実行される頻度を制限する手法です。この場合、ユーザーが入力を停止したときにのみデータを照会します。
使用される理由
キーボード入力のように頻繁に発生するイベントに対して、毎回サーバーへのリクエストを行うと、大量のユーザーが同時に利用している場合、システムに過度な負荷がかかります。
debounceを利用することで、入力が完了して、少し間を置いてからデータの更新や検索が行われるため、サーバーへの負担を減らすことができます。

仕組み
トリガー イベント: debounceする必要があるイベント (検索ボックスでの入力など) が発生すると、タイマーが開始されます。
待機: タイマーが期限切れになる前に新しいイベントが発生した場合、タイマーはリセットされます。
実行: タイマーがカウントダウンの終了に達すると、debounceされた関数が実行されます。

pnpm i use-debounce

3. データの取得

app/lib/data.ts
export async function fetchFilteredFoods(query: string) {
  const foods = [
    { id: '1', name: 'Pizza', category: 'Italian', price: 12.99, calories: 300 },
    { id: '2', name: 'Sushi', category: 'Japanese', price: 15.99, calories: 250 },
    { id: '3', name: 'Burger', category: 'American', price: 8.99, calories: 400 },
    { id: '4', name: 'Pasta', category: 'Italian', price: 10.99, calories: 350 },
    { id: '5', name: 'Salad', category: 'Healthy', price: 7.99, calories: 150 },
  ];

  const filteredFoods = foods.filter(food =>
    food.name.toLowerCase().includes(query.toLowerCase()) ||
    food.category.toLowerCase().includes(query.toLowerCase())
  );

  return filteredFoods;
}

解説:

  • この関数は、検索クエリを引数として受け取ります。
  • 実際のアプリケーションではデータベースからデータを取得しますが、ここでは静的なデータを使用しています。
  • filterメソッドを使用して、検索クエリに一致する食べ物アイテムをフィルタリングします。
  • 検索は食べ物の名前カテゴリーの両方に対して行われます。
  • 大文字小文字を区別しないように、toLowerCaseを使用しています。

4. テーブルの作成

components/foods/table.tsx
import Image from 'next/image';
import { formatCurrency } from '@/app/lib/utils';
import { fetchFilteredFoods } from '@/app/lib/data';

export default async function FoodsTable({
  query,
}: {
  query: string;
}) {
  const foods = await fetchFilteredFoods(query);

  return (
    <div className="mt-6 flow-root">
      <div className="inline-block min-w-full align-middle">
        <div className="rounded-lg bg-gray-50 p-2 md:pt-0">
          <div className="md:hidden">
            {foods?.map((food) => (
              <div
                key={food.id}
                className="mb-2 w-full rounded-md bg-white p-4"
              >
                <div className="flex items-center justify-between border-b pb-4">
                  <div>
                    <div className="mb-2 flex items-center">
                      <p>{food.name}</p>
                    </div>
                    <p className="text-sm text-gray-500">{food.category}</p>
                  </div>
                </div>
                <div className="flex w-full items-center justify-between pt-4">
                  <div>
                    <p className="text-xl font-medium">
                      {formatCurrency(food.price)}
                    </p>
                    <p>{food.calories} calories</p>
                  </div>
                </div>
              </div>
            ))}
          </div>
          <table className="hidden min-w-full text-gray-900 md:table">
            <thead className="rounded-lg text-left text-sm font-normal">
              <tr>
                <th scope="col" className="px-4 py-5 font-medium sm:pl-6">
                  Food
                </th>
                <th scope="col" className="px-3 py-5 font-medium">
                  Category
                </th>
                <th scope="col" className="px-3 py-5 font-medium">
                  Price
                </th>
                <th scope="col" className="px-3 py-5 font-medium">
                  Calories
                </th>
              </tr>
            </thead>
            <tbody className="bg-white">
              {foods?.map((food) => (
                <tr
                  key={food.id}
                  className="w-full border-b py-3 text-sm last-of-type:border-none [&:first-child>td:first-child]:rounded-tl-lg [&:first-child>td:last-child]:rounded-tr-lg [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg"
                >
                  <td className="whitespace-nowrap py-3 pl-6 pr-3">
                    <div className="flex items-center gap-3">
                      <p>{food.name}</p>
                    </div>
                  </td>
                  <td className="whitespace-nowrap px-3 py-3">
                    {food.category}
                  </td>
                  <td className="whitespace-nowrap px-3 py-3">
                    {formatCurrency(food.price)}
                  </td>
                  <td className="whitespace-nowrap px-3 py-3">
                    {food.calories} calories
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
}

まとめ

Next.jsのLearnの検索機能に関して振り返り記事を書きました。
検索機能に関しては、簡単に実装が出来たものの、色々と深掘りしていくと奥が深いですね。

Discussion