🪡

nuqsで絞り込みとページネーションを実装してみる

に公開

アプリケーションで絞り込みやページネーション機能を実装する際、検索パラメータ(Search Params)を使って状態を管理するのが一般的です。従来のURLSearchparamsを使った実装では、URLとReactの状態を同期する必要があるなど、何かとコードが混み入りがちな印象を持っていました。
そこで、検索パラーメタによる型安全な状態管理を提供するnuqsを使ってみたところ、今後も活用していきたと思えるライブラリだったので記事にまとめようと思います。

実装の全体像

今回は、Next.jsshadcn-uiで絞り込みとページネーション付きAPIの呼び出し部分を実装してみます。記事の構成は以下の通りです。

  1. 検索パラメータのスキーマを定義
  2. ページコンポーネントで検索パラメータをパース
  3. セレクトコンポーネント(Client Component)で検索パラメータを取得・更新
  4. テーブルコンポーネント (Server Component) でAPI呼び出し

スキーマ定義

まずは、検索パラメータのスキーマを定義します。Next.jsでは、HTTP標準の通りの型で検索パラーメタを受け取れるのですが、ページのインデックスなど、もっと狭い型を扱いたい場面が多々あります。

// Next.jsで取得できる検索パラメータの型
type SearchParams = {
    [x: string]: string | string[] | undefined;
}

そこで、より狭く厳密な型をスキーマとして定義して、検索パラメータをスキーマでパースします。nuqsは組み込みのパーサーとカスタムパーサーをつくる手段を提供していますが、今回は以下の組み込みパーサーのみを使用します。

// 検索パラメータのスキーマ
export const petSearchParamsSchema = {
  page: parseAsInteger.withDefault(START_PAGE_INDEX), // ページのインデックス = 1
  petType: parseAsStringLiteral<PetType>(
    Object.values(PetTypeEnum), // ペットの種類 🐈 or 🐶 or 🐟
  ),
};
文字列Enumの構成要素

スキーマで使用したオブジェクトとその型、定数は以下のように定義しています。
詳細はこちらの記事をご覧ください。

export const PetTypeEnum = {
  CAT: "cat",
  DOG: "dog",
  FISH: "fish",
} as const satisfies Record<string, PetType>;

export const petTypeOptions = {
  cat: "猫",
  dog: "犬",
  fish: "魚",
} as const satisfies Record<PetType, string>;

export const START_PAGE_INDEX = 1;

ページコンポーネントの実装

Next.jsにおいては、ページコンポーネントのPropsを使ってサーバーサイドの検索パラメータを取得します。これをloaderとスキーマでパースすることで、任意の型のオブジェクトとして扱うことができます。

page.tsx
const Page = async ({ searchParams }: searchParams: Promise<SearchParams>) => {
  // loaderで検索パラメータをパースして、型安全なオブジェクトを取得
  // `{ page: number, petType: "cat" | "dog" | "fish" }` として扱える
  const params = await createLoader(petSearchParamsSchema)(searchParams);

  // ...
};

セレクトコンポーネントの実装

セレクトコンポーネントは見た目 (View) と振る舞い (検索パラメータの更新) でファイルを分割しました。まずは、見た目の部分から見ていきましょう。

"use client";

export const PetTypeSelect = () => {
  // カスタムフックから現在の値と更新関数を取得
  const { petType, updatePetSearchParams } = usePetSearchParams();
  // セレクトのオプション形式に変換
  const options = [
    { value: "all", label: "すべて" },
    ...Object.entries(petTypeOptions).map(([value, label]) => ({
      value,
      label,
    })),
  ];


  return (
    <div className="w-[160px] [&_button]:w-full">
      <Select
        value={petType ?? "all"}
        onValueChange={(value: string) => {
          updatePetSearchParams({
            petType:
              value === "all"
                ? null // `all` の場合は `null` を設定 (フィルターを解除)
                : (value as PetSearchParamsSchemaType["petType"]),
          });
        }}
      >
        <SelectTrigger>
          <SelectValue placeholder="ペットの種類" />
        </SelectTrigger>
        <SelectContent>
          {options.map(({ value, label }) => (
            <SelectItem key={value} value={value}>
              {label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
  );
};
スキーマの型

Zodと同じようなインターフェースでスキーマの型を取得できます。

export type PetSearchParamsSchemaType = inferParserType<
  typeof petSearchParamsSchema
>;

続いて、振る舞いを切り出したカスタムフックです。クライアントサイドでは、useQueryStatesなどのフックで検索パラメータを取得できます。useState に近いインターフェースとなっているため、普段Reactを書いている人には馴染みやすいと思います。

export const usePetSearchParams = () => {
  // 検索パラメータを状態管理
  const [searchParams, setSearchParams] = useQueryStates(
    petSearchParamsSchema,
    {
      shallow: false, // サーバーサイドで検索パラメータの変更を検知するための設定
    },
  );
  const { page, petType } = searchParams;

  // 検索パラメータの更新関数
  const updatePetSearchParams = useCallback(
    (params: Partial<PetSearchParamsSchemaType>) => {
      setSearchParams({
        // 新しい検索パラメータがあれば更新
        petType: params.petType !== undefined ? params.petType : petType,
        page: START_PAGE_INDEX, // 絞り込み条件が変わったら最初のページに戻る
      });
    },
    [setSearchParams, petType],
  );

  return {
    page,
    petType,
    updatePetSearchParams,
  };
};

テーブルコンポーネントの実装

テーブルコンポーネントは、Propsで受け取った検索パラメータでAPIを呼び出します。データ取得はサーバーサイドで行い、見た目の部分は別のコンポーネントに切り出しました。

export const PetTableContainer = async ({
  page,
  petType,
}: PetSearchParamsSchemaType) => {
  // ページネーションのため、オフセット (取得開始位置) を計算
  const offset = (page - 1) * ITEM_PER_PAGE;

  // APIを呼び出してデータを取得
  const { items, count } = await getPets({
    limit: ITEM_PER_PAGE, // ページごとのデータ件数 = 10
    offset,
    petType: petType ?? undefined,
  });

  return <PetTablePresenter items={items} totalCount={count} />;
};

おわりに

今回はnuqsを使った検索パラメータの扱い方を学びました。絞り込みやページネーションはフロントエンドでよく出てくる機能なだけに、nuqsの直感的なAPI (React標準のフック、Zodのスキーマ定義など、馴染みのライブラリに近い) は魅力的です。今後も積極的に使っていこうと思います。

株式会社FLAT テックブログ

Discussion