【Next.js】超簡単!! Next.jsで検索機能の実装方法
はじめに
Learn Next.jsで検索機能を復習したので、その振り返り記事を記載していきます。
今回実装する検索機能について
ユーザーがクライアントで食べ物
を検索すると、URL パラメータが更新され、サーバー上でデータを取得、新しいデータを使用してサーバー上でテーブルが再レンダリングされる流れで検索機能を実装していきます。
URL 検索パラメータを使用する利点
-
ブックマークおよび共有可能なURL:
検索パラメータをURLに含めることにより、ユーザーがそのURLをブックマークしたり、他のユーザーと簡単に共有したりすることができます。これにより、特定の検索結果やフィルター設定を簡単に再現できるようになります。 -
サーバー側レンダリングと初期ロードの最適化:
URLパラメータをサーバー側で処理することで、初期ページロード時にクライアントに対して完全にレンダリングされたページを提供することができます。これは特に、SEO(検索エンジン最適化)の面で重要であり、パフォーマンスの向上にもつながります。 -
ユーザー体験の向上:
URLに検索状態を反映させることで、ユーザーが複数のタブやウィンドウで異なる検索結果を同時に開いたり、特定の検索状態に簡単に戻ることができます。 -
状態管理の簡素化:
クライアントサイドで状態を管理する代わりに、URLを状態の「Source of Truth」として使用することで、アプリケーションの状態管理が簡素化されます。URLが変更されると自動的にアプリケーションがその状態を反映するようになります。
検索機能で使用するNext.jsのclient hooks
-
useSearchParams
現在のURLのクエリパラメータにアクセスするために使用されます。検索やフィルタリングなどの機能を実装する際に非常に便利です。
例:
http://localhost:3000/food?page=1&query=pizza
パラメーターは次のようになります。{page: '1', query: 'pizza'}
-
usePathname
現在のページのパス名(URLのパス部分)を取得するために使用されます。例えば、アプリケーションの特定のセクションで特定のUIを表示したい場合や、パスに基づいて特定のデータをロードする際に役立ちます。 -
useRouter
Next.jsのルーティングシステムにアクセスするために使用されます。これによりプログラムによるルートの変更や、現在のルートの情報(パス、クエリ、パラメータなど)にアクセスできます。ナビゲーションの制御や、ページ遷移のカスタマイズが可能になります。
1. ページの作成
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. 検索機能の作成
'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. データの取得
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. テーブルの作成
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