レビュー済み·難易度: 初級·更新: 2026-04-18

React で検索キーワードに応じた 0 件 EmptyState を表示する

検索語の有無・フィルタ条件の有無によって異なる 0 件メッセージを出し分け、クリアボタンや別キーワード提案など検索導線につながる EmptyState を実装する例。

nextjssearch-filterui-component

対応バージョン

nextjs 15react 19

前提環境

React の基本的なコンポーネント実装を理解していること

概要

検索語とフィルタ条件の組み合わせによって 0 件になった原因をメッセージに反映し、クリア操作への導線を提供する EmptyState を実装する。汎用 EmptyState と異なり、実際の検索語をメッセージに埋め込んで「なぜ 0 件か」をユーザーに伝える。

インストール

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

実装

検索特化 EmptyState コンポーネント

// components/SearchEmptyState.tsx
type Props = {
  query?: string;
  hasActiveFilters?: boolean;
  onClearQuery?: () => void;
  onClearAllFilters?: () => void;
  suggestions?: string[];
};

export default function SearchEmptyState({
  query,
  hasActiveFilters = false,
  onClearQuery,
  onClearAllFilters,
  suggestions = [],
}: Props) {
  return (
    <div className="flex flex-col items-center px-6 py-16 text-center">
      {/* アイコン */}
      <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100">
        <svg
          xmlns="http://www.w3.org/2000/svg"
          className="h-8 w-8 text-gray-400"
          fill="none"
          viewBox="0 0 24 24"
          stroke="currentColor"
        >
          <path
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth={1.5}
            d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"
          />
        </svg>
      </div>

      {/* メッセージ:検索語があるとき */}
      {query && (
        <p className="mb-1 text-base font-semibold text-gray-800">
          <span className="text-blue-600">「{query}」</span> に一致する結果が見つかりません
        </p>
      )}

      {/* メッセージ:フィルタのみのとき */}
      {!query && hasActiveFilters && (
        <p className="mb-1 text-base font-semibold text-gray-800">
          現在のフィルタ条件に一致する結果がありません
        </p>
      )}

      {/* 補足テキスト */}
      <p className="mb-6 max-w-sm text-sm text-gray-500">
        {query && hasActiveFilters
          ? "キーワードを変えるか、フィルタ条件を解除してみてください。"
          : query
            ? "スペルを確認するか、別のキーワードをお試しください。"
            : "フィルタ条件を変更または解除してみてください。"}
      </p>

      {/* アクションボタン */}
      <div className="flex flex-wrap justify-center gap-2">
        {query && onClearQuery && (
          <button
            onClick={onClearQuery}
            className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
          >
            キーワードをクリア
          </button>
        )}
        {hasActiveFilters && onClearAllFilters && (
          <button
            onClick={onClearAllFilters}
            className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
          >
            すべてのフィルタを解除
          </button>
        )}
      </div>

      {/* 検索候補 */}
      {suggestions.length > 0 && (
        <div className="mt-8">
          <p className="mb-2 text-xs font-medium text-gray-500">こちらをお探しですか?</p>
          <div className="flex flex-wrap justify-center gap-2">
            {suggestions.map((s) => (
              <button
                key={s}
                onClick={() => onClearQuery?.()}
                className="rounded-full border px-3 py-1 text-sm text-blue-600 hover:bg-blue-50"
              >
                {s}
              </button>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

一覧コンポーネントへの組み込み

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

import { useState } from "react";
import SearchEmptyState from "./SearchEmptyState";

type Item = { id: number; name: string; category: string };

const ITEMS: Item[] = [
  { id: 1, name: "Next.js App Router", category: "routing" },
  { id: 2, name: "Tailwind CSS", category: "styling" },
  { id: 3, name: "Prisma ORM", category: "crud" },
  { id: 4, name: "Zod", category: "validation" },
  { id: 5, name: "Jest", category: "testing" },
];

const SUGGESTIONS = ["React", "TypeScript", "API", "Form"];

export default function SampleSearchList() {
  const [query, setQuery] = useState("");
  const [category, setCategory] = useState("");

  const filtered = ITEMS.filter((item) => {
    const matchesQuery = !query || item.name.toLowerCase().includes(query.toLowerCase());
    const matchesCategory = !category || item.category === category;
    return matchesQuery && matchesCategory;
  });

  const hasFilters = !!category;

  return (
    <div className="space-y-4">
      {/* 検索フォーム */}
      <div className="flex gap-2">
        <input
          type="search"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="キーワードで検索…"
          className="flex-1 rounded-lg border px-4 py-2 text-sm"
        />
        <select
          value={category}
          onChange={(e) => setCategory(e.target.value)}
          className="rounded-lg border px-3 py-2 text-sm"
        >
          <option value="">すべて</option>
          <option value="routing">routing</option>
          <option value="styling">styling</option>
          <option value="crud">crud</option>
          <option value="testing">testing</option>
        </select>
      </div>

      {/* 結果 or EmptyState */}
      {filtered.length === 0 ? (
        <SearchEmptyState
          query={query || undefined}
          hasActiveFilters={hasFilters}
          onClearQuery={() => setQuery("")}
          onClearAllFilters={() => {
            setQuery("");
            setCategory("");
          }}
          suggestions={query ? SUGGESTIONS : []}
        />
      ) : (
        <ul className="divide-y rounded-lg border">
          {filtered.map((item) => (
            <li key={item.id} className="flex items-center justify-between px-4 py-3">
              <span className="text-sm font-medium">{item.name}</span>
              <span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
                {item.category}
              </span>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

ポイント

  • 検索語 (query) とフィルタ条件 (hasActiveFilters) の有無で 3 パターンのメッセージを出し分ける。「なぜ 0 件か」の原因をメッセージに反映させることで、ユーザーが次に何をすべきかを理解しやすくなる
  • 検索語をメッセージに埋め込む(「${query}」に一致する…)ことで、ユーザーが入力した内容が確認でき、スペルミスに気づきやすくなる
  • suggestions にキーワード候補を渡すと、関連語のボタンを表示できる。候補をクリックするとクリア操作にフックして次の検索を促せる
  • クリアボタンは「キーワードをクリア」と「すべてのフィルタを解除」を分けて提供する。フィルタのみが原因のときはキーワードクリアボタンを非表示にしてノイズを減らす
  • 汎用 EmptyState(tailwind-empty-state-ui)との違い: この実装は検索語・フィルタ条件を Props として受け取りメッセージに反映させる点が特徴。汎用版は title / description を静的に渡す設計

注意点

tailwind-empty-state-ui は汎用 EmptyState パターン集(検索ゼロ・未登録・エラー後)。これは検索語・フィルタ条件の具体的な内容をメッセージに反映させ、クリアや関連候補提示につなげる検索特化の EmptyState に絞る。

関連サンプル