レビュー待ち·難易度: 中級·更新: 2026-04-18

React でチェックボックス複数選択フィルタを実装する

複数カテゴリのチェックボックスによる AND/OR フィルタリング UI をクライアントサイドで実装する例。フィルタロジックをコンポーネントから分離して管理する。

nextjssearch-filter

対応バージョン

nextjs 15react 19

前提環境

React の useState の基本を理解していること

概要

チェックボックスで複数のカテゴリ・タグを選択し、選択条件に一致するアイテムをクライアントサイドでフィルタリングする。フィルタロジックをコンポーネント外の純粋関数として分離することで、テストしやすい構成にする。

インストール

# 追加インストールは不要

実装

型定義とフィルタロジック

// lib/filterItems.ts
export type Item = {
  id: number;
  title: string;
  category: string;
  tags: string[];
};

export type FilterState = {
  categories: string[];
  tags: string[];
};

export function filterItems(items: Item[], filter: FilterState): Item[] {
  return items.filter((item) => {
    // カテゴリ: 何も選択されていなければ全通過
    const categoryMatch =
      filter.categories.length === 0 ||
      filter.categories.includes(item.category);

    // タグ: 選択されたタグをすべて含む(AND 条件)
    const tagMatch =
      filter.tags.length === 0 ||
      filter.tags.every((tag) => item.tags.includes(tag));

    return categoryMatch && tagMatch;
  });
}

export function toggleValue(arr: string[], value: string): string[] {
  return arr.includes(value)
    ? arr.filter((v) => v !== value)
    : [...arr, value];
}

フィルタパネル

// components/FilterPanel.tsx
"use client";

import type { FilterState } from "@/lib/filterItems";
import { toggleValue } from "@/lib/filterItems";

const CATEGORIES = ["フロントエンド", "バックエンド", "テスト", "インフラ"];
const TAGS = ["React", "Next.js", "TypeScript", "Zod", "Jest"];

type Props = {
  filter: FilterState;
  onChange: (next: FilterState) => void;
  resultCount: number;
};

export function FilterPanel({ filter, onChange, resultCount }: Props) {
  function toggleCategory(cat: string) {
    onChange({ ...filter, categories: toggleValue(filter.categories, cat) });
  }

  function toggleTag(tag: string) {
    onChange({ ...filter, tags: toggleValue(filter.tags, tag) });
  }

  function reset() {
    onChange({ categories: [], tags: [] });
  }

  const isFiltered = filter.categories.length > 0 || filter.tags.length > 0;

  return (
    <aside className="w-56 space-y-5">
      <div>
        <h3 className="mb-2 text-sm font-semibold text-gray-700">カテゴリ</h3>
        <ul className="space-y-1">
          {CATEGORIES.map((cat) => (
            <li key={cat}>
              <label className="flex cursor-pointer items-center gap-2 text-sm text-gray-600">
                <input
                  type="checkbox"
                  checked={filter.categories.includes(cat)}
                  onChange={() => toggleCategory(cat)}
                  className="rounded"
                />
                {cat}
              </label>
            </li>
          ))}
        </ul>
      </div>

      <div>
        <h3 className="mb-2 text-sm font-semibold text-gray-700">タグ(AND 絞り込み)</h3>
        <ul className="space-y-1">
          {TAGS.map((tag) => (
            <li key={tag}>
              <label className="flex cursor-pointer items-center gap-2 text-sm text-gray-600">
                <input
                  type="checkbox"
                  checked={filter.tags.includes(tag)}
                  onChange={() => toggleTag(tag)}
                  className="rounded"
                />
                {tag}
              </label>
            </li>
          ))}
        </ul>
      </div>

      <div className="border-t pt-3">
        <p className="mb-2 text-xs text-gray-500">{resultCount} 件</p>
        {isFiltered && (
          <button
            onClick={reset}
            className="text-xs text-blue-600 hover:underline"
          >
            フィルタをリセット
          </button>
        )}
      </div>
    </aside>
  );
}

アイテムリスト

// components/ItemList.tsx
import type { Item } from "@/lib/filterItems";

export function ItemList({ items }: { items: Item[] }) {
  if (items.length === 0) {
    return <p className="text-sm text-gray-500">該当するアイテムがありません</p>;
  }
  return (
    <ul className="space-y-2">
      {items.map((item) => (
        <li key={item.id} className="rounded border p-3">
          <p className="font-medium text-gray-800">{item.title}</p>
          <p className="text-xs text-gray-500">{item.category}</p>
          <div className="mt-1 flex flex-wrap gap-1">
            {item.tags.map((tag) => (
              <span
                key={tag}
                className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600"
              >
                {tag}
              </span>
            ))}
          </div>
        </li>
      ))}
    </ul>
  );
}

ページ(クライアントコンポーネント)

// app/page.tsx
"use client";

import { useState } from "react";
import { FilterPanel } from "@/components/FilterPanel";
import { ItemList } from "@/components/ItemList";
import { filterItems, type FilterState, type Item } from "@/lib/filterItems";

const ALL_ITEMS: Item[] = [
  { id: 1, title: "Next.js 入門", category: "フロントエンド", tags: ["React", "Next.js", "TypeScript"] },
  { id: 2, title: "Zod でバリデーション", category: "フロントエンド", tags: ["Zod", "TypeScript"] },
  { id: 3, title: "Jest でテスト", category: "テスト", tags: ["Jest", "TypeScript"] },
  { id: 4, title: "React フック", category: "フロントエンド", tags: ["React", "TypeScript"] },
  { id: 5, title: "API 設計", category: "バックエンド", tags: ["TypeScript"] },
];

export default function Page() {
  const [filter, setFilter] = useState<FilterState>({ categories: [], tags: [] });
  const filtered = filterItems(ALL_ITEMS, filter);

  return (
    <main className="mx-auto max-w-3xl p-8">
      <h1 className="mb-6 text-2xl font-bold">コンテンツ一覧</h1>
      <div className="flex gap-8">
        <FilterPanel filter={filter} onChange={setFilter} resultCount={filtered.length} />
        <div className="flex-1">
          <ItemList items={filtered} />
        </div>
      </div>
    </main>
  );
}

ポイント

  • フィルタロジック(filterItems / toggleValue)を純粋関数として lib/ に分離することで、コンポーネントの再レンダリングと独立してテストできる
  • カテゴリは OR 条件(いずれかに一致)、タグは AND 条件(すべてを含む)として実装。用途に応じて切り替えやすい
  • toggleValue でチェックボックスの選択・解除を配列操作で表現する。不変性を保つため filter() と スプレッドで新配列を返す
  • nextjs-search-params-filter との使い分け: URL に状態を保存したい(ブックマーク・シェア対応)場合は searchParams 版、クライアント内で完結する場合はこのパターン

注意点

nextjs-search-params-filter は URL クエリ + Server Component。react-debounce-search はテキスト入力デバウンス。これはクライアント側のチェックボックス複数選択フィルタ(カテゴリ × タグ等の組み合わせ)。

関連サンプル