検索機能を作る

2024-12-06

react
Example Image

Nextのドキュメントを読みながら、ページネーションつきの検索機能を作ります!

App Router: Adding Search and Pagination...

Add search and pagination to your dashbo...

favicon nextjs.org

OGP image

作りたいものはこれ

テキストを入力する、またはカテゴリーを選択すると、シュッとデータを取得して結果を表示します。

検索実行ボタンをクリックしたり、エンターキーを押したりする必要はないでりす。

それではいきます!

APIを作る

api/search/route.tsにAPIのルートを作ります。

filter(検索ワード)、category(カテゴリー)、page(ページ番号)をもとにmarkers(検索結果)を返すようにします。

ページネーションを表示したいので、markersと一緒にhitCount(検索にhitした件数)も返すようにします。

import prisma from '../../lib/db';
import { type NextRequest, NextResponse } from 'next/server';

export const dynamic = 'force-dynamic';

export async function GET(req: NextRequest) {
  try {
    const page = Number(req.nextUrl.searchParams.get('page') || '1');
    const filter = req.nextUrl.searchParams.get('filter') || '';
    const category = req.nextUrl.searchParams.get('category') || '';

    const whereClause = {
      title: {
        contains: filter,
      },
      ...(category && { category: category }),
    };

    const hitCount = await prisma.marker.count({
      where: whereClause,
    });

    const markers = await prisma.marker.findMany({
      where: whereClause,
      take: 3,
      skip: (page - 1) * 3,
    });

    return NextResponse.json({
      markers,
      hitCount,
    });
  } catch (error) {
    console.error('Request error', error);
    return NextResponse.json(
      { error: 'Error fetching markers' },
      { status: 500 }
    );
  }
}

ブラウザのurlに「http://localhost:3000/api/search?filter=島」のように入力すると、下記のように検索結果のレスポンスが返ってくるようになります。

ページを作る

page.tsxにSearchコンポーネント(ユーザーが入力する部分)とTableコンポーネント(検索結果)を並べます。

import MarkerLists from '../components/MarkerLists';
import Search from '../components/Search';
import { Suspense } from 'react';

export default async function StaticPage() {
  return (
    <div>
      <Search />
      <Table />
    </div>
  );
}

検索機能を作る

検索の状態管理には、useSearchParamsを使います。

useStateでも検索機能は作れますが、useSearchParamsだと検索結果をブックマークできたり、リロード時に検索結果を保持できたり等のメリットがありいい感じです。

検索機能のざっくりした流れ

  • ユーザーの入力内容をパラメータとして保持する
  • 保持したパラメータを用いてurlを更新する
  • 検索データを取得する

下記のようにSeach.tsxを作ります。

'use client';

import { useSearchParams, usePathname, useRouter } from 'next/navigation';

export default function EnhancedSearch() {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();

  function handleSearchInput(term: string) {
    const params = new URLSearchParams(searchParams);
    if (term) {
      params.set('filter', term);
    } else {
      params.delete('filter');
    }
    params.set('page', '1');
    replace(`${pathname}?${params.toString()}`);
  }

  function handleSearchCategory(term: string) {
    const params = new URLSearchParams(searchParams);
    if (term !== 'all') {
      params.set('category', term);
    } else {
      params.delete('category');
    }
    params.set('page', '1');
    replace(`${pathname}?${params.toString()}`);
  }

  return (
    <div>
      <div>
        <Search />
        <Input
          placeholder="検索..."
          onChange={(e) => handleSearchInput(e.target.value)}
          defaultValue={searchParams.get('filter') ?? ''}
          aria-label="検索入力"
        />
      </div>
      <Select
        onValueChange={handleSearchCategory}
        defaultValue={searchParams.get('category') ?? 'all'}
      >
        <SelectTrigger>
          <SelectValue placeholder="カテゴリー選択" />
        </SelectTrigger>
        <SelectContent>
          <SelectItem value="all">select category</SelectItem>
          <SelectItem value="観光">観光</SelectItem>
          <SelectItem value="食事">食事</SelectItem>
          <SelectItem value="家族">家族</SelectItem>
        </SelectContent>
      </Select>
    </div>
  );
}

inputのonChangeイベントで、ユーザーの入力を保持します。

useSearchParamsで、現在いるページのパラメータを{page: '1', filter: '島'}のように取得します。

usePathnameで、現在いるページのurlを「http://localhost:3000」のように取得します。

params.set('filter', term)のところで、ユーザーの入力をパラメータにセットします。

useRouterのreplaceを使ってurlを更新します。

defaultValueでinputの値とパラメータの値を同期させます。

Searchの役目は、ユーザーの入力をurlに反映させることだよ

検索結果を表示させる

urlにセットしたパラメータは、他のコンポーネントに使いまわすことができます。

Searchコンポーネントはクライアント側のコンポーネントなのでuseSearchParamsを使用していましたが、

Tableコンポーネントはサーバー側でデータを取得するコンポーネントなのでsearchParamsを使用します。

page.tsxでパラメータを取得し、値をTableに渡します。

Suspenseのkeyにパラメータを指定することで、パラメータが変更された時にTableを再描画するようにできます。

import MarkerLists from '../components/MarkerLists';
import Search from '../components/Search';
import { Suspense } from 'react';

export default async function StaticPage(props: {
  searchParams?: Promise<{
    filter?: string;
    category?: string;
    page?: string;
  }>;
}) {
  const searchParams = await props.searchParams;
  const filter = searchParams?.filter || '';
  const category = searchParams?.category || '';
  const currentPage = Number(searchParams?.page) || 1;

  return (
    <div>
      <Search />
      <Suspense key={filter + category + currentPage} fallback={<div>loading...</div>}>
        <Table filter={filter} category={category} page={currentPage} />
      </Suspense>
    </div>
  );
}

Tableコンポーネントでは、page.tsxから受け取ったパラメータを元にデータを取得します。

また、hitCountをPaginationコンポーネントに渡します。

export default function MarkerLists({
  filter,
  category,
  page,
}: {
  filter: string;
  category: string;
  page: number;
}) {

  const { data, isLoading, isError, error } = useQuery<SearchResponse, Error>({
    queryKey: ['searchMarkers', { page, filter, category }],
    queryFn: () => searchMarkers({ page, filter, category }),
  });

...

  return (
    <div>
      <div>
        {data.markers.map((marker) => (        
...
      </div>
      <Pagination hitCount={data.hitCount} />
    </div>
  );
}

データを取得する関数はこんな感じです。引数で受け取ったパラメータを、urlに含めてAPIと通信しています。

const searchMarkers = async ({
  page,
  filter,
  category,
}: {
  page: number;
  filter: string;
  category: string;
}) => {
  try {
    const res = await fetch(
      `/api/search?page=${page}&filter=${filter}&category=${category}`
    );
    if (!res.ok) {
      throw new Error(`Failed to fetch markers: ${res.status}`);
    }

    const data: SearchResponse = await res.json();
    return data;
  } catch (error) {
    console.error('Error in fetchMarkers:', error);
    throw error;
  }
};

ページネーションを作る

Paginationコンポーネントがやっていることはシンプルです!

urlのパラメータのpageのところを、クリック先のページ番号に書き変えています。

...
export default function Pagination({ hitCount }: PaginationProps): JSX.Element {
  const { replace } = useRouter();
  const pageVolume = 3;

  const totalPages = Math.ceil(hitCount / pageVolume);

  const pathname = usePathname();
  const searchParams = useSearchParams();
  const currentPage = Number(searchParams.get('page')) || 1;
  const isLast = hitCount <= currentPage * pageVolume;

  const createPageURL = (pageNumber: number | string) => {
    const params = new URLSearchParams(searchParams);
    params.set('page', pageNumber.toString());
    return `${pathname}?${params.toString()}`;
  };

  const handlePrevPageClick = (): void => {
    replace(createPageURL(currentPage - 1));
  };

  const handleNextPageClick = (): void => {
    replace(createPageURL(currentPage + 1));
  };

  const handleButtonPageClick = (index: number) => {
    replace(createPageURL(index + 1));
  };

  return (
    <div>
      <button
        onClick={handlePrevPageClick}
        disabled={currentPage === 1}
      >
        <ChevronLeft />
      </button>

      <ul>
        {Array.from({ length: totalPages }, (_, index) => (
          <li key={index}>
            {currentPage === index + 1 ? (
              <span>
                {index + 1}
              </span>
            ) : (
              <Button
                onClick={() => handleButtonPageClick(index)}
              >
                {index + 1}
              </Button>
            )}
          </li>
        ))}
      </ul>
      <button
        onClick={handleNextPageClick}
        disabled={isLast}
      >
        <ChevronRight />
      </button>
    </div>
  );
}

TableコンポーネントからもらったhitCountは、総ページ数を算出するのに使っています。

Array.from({ length: totalPages }, (_, index) => ...) でページ番号を並べることができます。

完成した

今まで作った検索機能で一番いい感じのができました🙌

share