概要
チェックボックスで複数のカテゴリ・タグを選択し、選択条件に一致するアイテムをクライアントサイドでフィルタリングする。フィルタロジックをコンポーネント外の純粋関数として分離することで、テストしやすい構成にする。
インストール
# 追加インストールは不要
実装
型定義とフィルタロジック
// 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 版、クライアント内で完結する場合はこのパターン