SCALE — Build Lab
開発パターン · REACT PATTERN

検索・フィルタパターン

CATEGORY開発パターン TYPEReact Pattern EFFORT120〜360分 DIFFICULTY
PRIMARY CODE
tsx
import { useState, useMemo, useEffect } from 'react';

// 一覧の検索・複数フィルタ・ソートを統合
type Filters = Record<string, string | undefined>;
type SortBy<T> = { field: keyof T; dir: 'asc' | 'desc' };

export function useFilteredList<T extends Record<string, any>>(
  items: T[],
  searchFields: (keyof T)[],
) {
  const [q, setQ] = useState('');
  const [filters, setFilters] = useState<Filters>({});
  const [sort, setSort] = useState<SortBy<T> | null>(null);

  // URL クエリ同期
  useEffect(() => {
    const params = new URLSearchParams();
    if (q) params.set('q', q);
    Object.entries(filters).forEach(([k, v]) => v && params.set(k, v));
    if (sort) params.set('sort', `${String(sort.field)}:${sort.dir}`);
    const url = params.toString() ? `?${params.toString()}` : window.location.pathname;
    window.history.replaceState({}, '', url);
  }, [q, filters, sort]);

  const result = useMemo(() => {
    let out = items;
    if (q) {
      const ql = q.toLowerCase();
      out = out.filter((it) => searchFields.some((f) => String(it[f] ?? '').toLowerCase().includes(ql)));
    }
    Object.entries(filters).forEach(([k, v]) => {
      if (v) out = out.filter((it) => String(it[k as keyof T]) === v);
    });
    if (sort) {
      out = [...out].sort((a, b) => {
        const dir = sort.dir === 'asc' ? 1 : -1;
        return a[sort.field] > b[sort.field] ? dir : -dir;
      });
    }
    return out;
  }, [items, q, filters, sort, searchFields]);

  return { q, setQ, filters, setFilters, sort, setSort, result };
}
前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • 全一覧画面

検索・フィルタパターン

:LiTarget: 用途

一覧画面の検索・複数フィルタ・ソートを統合管理するパターン。

:LiSparkle: 特徴

  • 複数条件
  • URLクエリ同期
  • ファセット
  • パフォーマンス対策

:LiCode: コード(コピペ用)

import { useState, useMemo, useEffect } from 'react';

// 一覧の検索・複数フィルタ・ソートを統合
type Filters = Record<string, string | undefined>;
type SortBy<T> = { field: keyof T; dir: 'asc' | 'desc' };

export function useFilteredList<T extends Record<string, any>>(
  items: T[],
  searchFields: (keyof T)[],
) {
  const [q, setQ] = useState('');
  const [filters, setFilters] = useState<Filters>({});
  const [sort, setSort] = useState<SortBy<T> | null>(null);

  // URL クエリ同期
  useEffect(() => {
    const params = new URLSearchParams();
    if (q) params.set('q', q);
    Object.entries(filters).forEach(([k, v]) => v && params.set(k, v));
    if (sort) params.set('sort', `${String(sort.field)}:${sort.dir}`);
    const url = params.toString() ? `?${params.toString()}` : window.location.pathname;
    window.history.replaceState({}, '', url);
  }, [q, filters, sort]);

  const result = useMemo(() => {
    let out = items;
    if (q) {
      const ql = q.toLowerCase();
      out = out.filter((it) => searchFields.some((f) => String(it[f] ?? '').toLowerCase().includes(ql)));
    }
    Object.entries(filters).forEach(([k, v]) => {
      if (v) out = out.filter((it) => String(it[k as keyof T]) === v);
    });
    if (sort) {
      out = [...out].sort((a, b) => {
        const dir = sort.dir === 'asc' ? 1 : -1;
        return a[sort.field] > b[sort.field] ? dir : -dir;
      });
    }
    return out;
  }, [items, q, filters, sort, searchFields]);

  return { q, setQ, filters, setFilters, sort, setSort, result };
}

:LiHandPointer: 使い方

対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。

:LiAlertCircle: 注意事項

  • 依存パッケージを忘れず追加