概要
よく使う検索条件(キーワード・カテゴリ・難易度)に名前を付けて 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にプリセット名を含めることで、スクリーンリーダーが「○○を削除」と読み上げられる