📚

Server Components で複数選択できるフィルタリングを実装する

2023/11/19に公開

Web ページ上にフィルタリング機能を実装したい場面は頻繁に遭遇します。例えば、ブログの記事一覧ページにて複数個のタグを選択し、その状態に応じて表示する記事を変えるなどです。

hongoshi, tech, design, random の4つのタグが並んでおり、これらを選択することで、記事一覧の絞り込みを行うスクリーンショット。
やりたいこと(https://いなにわうどん.みんな/articles で使用)

こうした場合に、従来の React では選択中のタグを useState 等の hooks に持たせ、その内容に応じてフィルタリングした結果を useMemo 等に記憶させることが一般的でした。しかしながら、Next.js 13 から標準で採用されている React Server Components(RSC)では状態や副作用を持つことができないため、新たな状態管理の手法を考える必要があります。

解決策

URL 中のクエリパラメータを利用してタグの選択を表現します。例えば、パラメータ名を tag とするとき、パラメータと状態の紐づけとして以下を考えます。

  • ?:タグが未選択
  • ?tag=foo:タグ foo のみが選択されている
  • ?tag=foo+bar:タグ foo, bar が複数選択されている

next/link を用いてこれらのパラメータを付与しながら遷移することで、クライアントでの状態管理に依存することなく、フィルタリング機能が実装できます。JS が無効な環境からのアクセスでも動作する点もポイントです[1]

実装

useRouter に代わって、Next.13 では NextPage の props として searchParams が取得できます[2]

interface PageProps {
  searchParams: { [key in string]: string | string[] | undefined }
}

searchParams が string 型であった場合に限って、デリミタ(+)で分割した値を選択中のタグとして利用します。string 型の場合は初期値(今回の場合は空配列)としておきます。

page.tsx
import CheckBox from "./CheckBox";

// コンテンツ
type Item = { tag: string; content: string; }
const items: Item[] = [
  { tag: "tech", content: "技術" },
  { tag: "random", content: "雑談" },
  { tag: "tech", content: "技術 2(ツー)" },
  { tag: "tech", content: "雑談 2(ツー)" },
];

const tags = ["tech", "random"];

const Page = ({ searchParams }: PageProps) => {
  const tagParams = searchParams["tag"];
  const selectedTags = typeof tagParams === "string" ? tagParams.split("+") : [];
  const filteredItems = items.filter(item => selectedTags.includes(item.tag));
  
  return (
    <div>
      <CheckBox paramKey="tag" tags={tags} searchParams={searchParams} />
      <ul>
        {filteredItems.map((item) => (
          <li key={item.content}>{item.content}</li>
        ))}
      </ul>
    </div>
  );
}

export default Page;

複数選択できるタグ

タグの選択を実現するコンポーネントは以下の実装になります。
URL の履歴にパラメータの変遷を残したくない場合は、Link コンポートに replace 属性を指定します。

CheckBox.tsx
import Link from 'next/link';

interface CheckboxProps {
  paramKey: string;
  tags: string[];
  searchParams: { [key in string]: string | string[] | undefined };
}

const Checkbox = ({ paramKey, tags, searchParams }: CheckboxProps) => {
  // タグが選択中であるか
  const isSelected = (tag: string) => {
    const param = searchParams[paramKey];
    return typeof param === 'string' && param.split('+').includes(tag);
  };

  // タグの選択を追加 or 解除する
  const getNewParams = (tag: string) => {
    // string 型であるパラメータのみを抽出
    const newParams: { [key in string]: string } = {};
    for (const [key, value] of Object.entries(searchParams)) {
      if (typeof value === 'string') {
        newParams[key] = value;
      }
    }
    if (!newParams[paramKey]) {
      newParams[paramKey] = '';
    }
    const oldTags = newParams[paramKey].split('+');
    const newKeys = oldTags.includes(tag)
      ? oldTags.filter((key) => key !== tag)
      : [...oldTags, tag];
    newParams[paramKey] = newKeys.join('+');

    return new URLSearchParams(newParams);
  };

  return (
    <ul>
      {tags.map((tag) => (
        <li key={tag}>
          <Link href={`?${getNewParams(tag)}`}>
            {tag} {isSelected(tag) && '選択中'}
          </Link>
        </li>
      ))}
    </ul>
  );
};

export default Checkbox;
脚注
  1. Server Actions では、JS が無効でも動作する機能を Progressive Enhancement と呼称している ↩︎

  2. useSearchParams を使用する方法もありますが、これは Client Components 専用です ↩︎

Discussion