© TOOLS BOX — Next.js / React / TypeScript コードサンプル集

サンプルガイド
←サンプル一覧
nextjssearch-filter

React でよく使うフィルタ条件をプリセットとして保存・再適用する

検索条件(キーワード・カテゴリ・難易度など)をプリセットとして localStorage に保存し、一覧画面からワンクリックで再適用できる UI パターン。

難易度: 初級·更新: 2026-04-18

対応バージョン

nextjs 15react 19

前提環境

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

概要

よく使う検索条件(キーワード・カテゴリ・難易度)に名前を付けて localStorage に保存し、一覧画面のプリセットボタンからワンクリックで再適用する UI を実装する。保存・削除・適用を useFilterPreset フックに集約し、コンポーネントはボタン操作だけを担う。

インストール

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

実装

フィルタ条件の型

// types/filter.ts

export type FilterParams = {
  q?: string;
  framework?: string;
  category?: string;
  difficulty?: string;
};

export type FilterPreset = {
  id: string;
  name: string;
  params: FilterParams;
};

プリセット管理フック

// hooks/useFilterPreset.ts
import { useState, useEffect } from "react";
import type { FilterParams, FilterPreset } from "@/types/filter";

const STORAGE_KEY = "filter_presets";

function loadPresets(): FilterPreset[] {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    return raw ? (JSON.parse(raw) as FilterPreset[]) : [];
  } catch {
    return [];
  }
}

function savePresets(presets: FilterPreset[]): void {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
}

export function useFilterPreset() {
  const [presets, setPresets] = useState<FilterPreset[]>([]);

  useEffect(() => {
    setPresets(loadPresets());
  }, []);

  const addPreset = (name: string, params: FilterParams) => {
    const next: FilterPreset[] = [
      ...presets,
      { id: crypto.randomUUID(), name, params },
    ];
    setPresets(next);
    savePresets(next);
  };

  const removePreset = (id: string) => {
    const next = presets.filter((p) => p.id !== id);
    setPresets(next);
    savePresets(next);
  };

  return { presets, addPreset, removePreset };
}

プリセット保存フォーム

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

import { useState } from "react";
import type { FilterParams } from "@/types/filter";

type Props = {
  currentParams: FilterParams;
  onSave: (name: string, params: FilterParams) => void;
};

export function SavePresetForm({ currentParams, onSave }: Props) {
  const [name, setName] = useState("");

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!name.trim()) return;
    onSave(name.trim(), currentParams);
    setName("");
  };

  return (
    <form onSubmit={handleSubmit} className="flex gap-2">
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="プリセット名"
        className="rounded border border-gray-200 px-3 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-blue-400"
        aria-label="プリセット名を入力"
      />
      <button
        type="submit"
        disabled={!name.trim()}
        className="rounded bg-blue-600 px-3 py-1 text-sm text-white disabled:opacity-40"
      >
        保存
      </button>
    </form>
  );
}

プリセット一覧と適用 UI

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

import type { FilterPreset } from "@/types/filter";

type Props = {
  presets: FilterPreset[];
  onApply: (preset: FilterPreset) => void;
  onRemove: (id: string) => void;
};

export function FilterPresetList({ presets, onApply, onRemove }: Props) {
  if (presets.length === 0) {
    return <p className="text-sm text-gray-400">保存済みプリセットはありません</p>;
  }

  return (
    <ul className="flex flex-wrap gap-2">
      {presets.map((preset) => (
        <li key={preset.id} className="flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 pl-3 pr-1 py-1">
          <button
            onClick={() => onApply(preset)}
            className="text-sm text-gray-700 hover:text-blue-600"
          >
            {preset.name}
          </button>
          <button
            onClick={() => onRemove(preset.id)}
            aria-label={`「${preset.name}」を削除`}
            className="rounded-full p-0.5 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
          >
            <svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
              <path d="M4.293 4.293a1 1 0 011.414 0L8 6.586l2.293-2.293a1 1 0 111.414 1.414L9.414 8l2.293 2.293a1 1 0 01-1.414 1.414L8 9.414l-2.293 2.293a1 1 0 01-1.414-1.414L6.586 8 4.293 5.707a1 1 0 010-1.414z" />
            </svg>
          </button>
        </li>
      ))}
    </ul>
  );
}

一覧ページへの組み込み

// app/samples/FilterPresetPanel.tsx
"use client";

import { useRouter } from "next/navigation";
import { useFilterPreset } from "@/hooks/useFilterPreset";
import { SavePresetForm } from "@/components/SavePresetForm";
import { FilterPresetList } from "@/components/FilterPresetList";
import type { FilterParams, FilterPreset } from "@/types/filter";

type Props = {
  currentParams: FilterParams;
};

export function FilterPresetPanel({ currentParams }: Props) {
  const { presets, addPreset, removePreset } = useFilterPreset();
  const router = useRouter();

  const handleApply = (preset: FilterPreset) => {
    const params = new URLSearchParams();
    if (preset.params.q) params.set("q", preset.params.q);
    if (preset.params.framework) params.set("framework", preset.params.framework);
    if (preset.params.category) params.set("category", preset.params.category);
    if (preset.params.difficulty) params.set("difficulty", preset.params.difficulty);
    router.push(`/samples?${params.toString()}`);
  };

  return (
    <div className="space-y-3 rounded border border-gray-100 p-4">
      <h2 className="text-sm font-medium text-gray-700">プリセット</h2>
      <FilterPresetList
        presets={presets}
        onApply={handleApply}
        onRemove={removePreset}
      />
      <SavePresetForm currentParams={currentParams} onSave={addPreset} />
    </div>
  );
}

ポイント

  • useEffect 内で localStorage を読み込むことで、SSR 時に localStorage is not defined エラーを避ける。初回クライアントレンダリング後に読み込まれる
  • crypto.randomUUID() でプリセット ID を生成する。ブラウザネイティブ API のため依存なしで安全な一意 ID が得られる
  • プリセットの適用は URLSearchParams で URL を組み立てて router.push する。currentParams を直接 state にするのではなく URL で管理することで、ブラウザの戻るボタンでも履歴が正しく機能する
  • try/catch で JSON.parse をラップする。localStorage の値が壊れていた場合に空配列で安全にフォールバックできる
  • プリセット名の trim() チェックにより、空白のみの名前での保存を防ぐ。disabled 属性と組み合わせてボタンを無効化する
  • 削除ボタンの aria-label にプリセット名を含めることで、スクリーンリーダーが「○○を削除」と読み上げられる

注意点

nextjs-url-filter-reset はフィルタ条件のリセット。tailwind-filter-chip-ui は現在の条件のチップ表示。これは繰り返し使う検索条件に名前を付けて保存し一覧から再適用するプリセット管理 UI に特化。

関連サンプル

同じテーマや技術スタックを使った実装例

  • Jest で URLSearchParams を使った URL クエリ変換ロジックをテストする

    URLSearchParams を操作するフィルタクエリ生成・パース関数を Jest でユニットテストする例。jsdom 環境での URLSearchParams の挙動とエッジケースの検証パターン。

  • Next.js で検索条件を保持したパンくずリストを生成する

    カテゴリ・フィルタ・キーワードなどの現在の検索コンテキストをパンくず形式で表示し、各ステップをクリックして段階的に条件を外せる戻り導線パターン。

  • Next.js で現在の検索条件を保持したままページ遷移するリンクを生成する

    一覧ページの検索語・フィルタ・sort・page を URL クエリとして保持し、詳細ページや別導線へ遷移後に戻ったとき条件が復元されるリンク生成パターン。

  • Prisma でフィルタ条件を動的に組み立ててクエリを実行する

    URL クエリパラメータから受け取った検索条件を Prisma の where 句に動的に組み立て、キーワード・カテゴリ・難易度などの複合フィルタクエリを実行するパターン。

  • Prisma で複数カラムをまたぐキーワード検索クエリを実装する

    タイトル・本文・タグなど複数カラムをまたぐキーワード検索を Prisma の OR 条件と fulltext 検索で実装するパターン。単語分割・スコアリングなしの実用的な部分一致パターンを示す。

関連仕様

このサンプルを理解するのに役立つ仕様や概念

  • FrameworkNext.jsReact ベースのフルスタックフレームワーク。SSR・SSG・App Router・API Routes を提供する。
←サンプル一覧に戻る