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

React で検索結果が 0 件のときに関連キーワードを提案する

検索結果が 0 件のとき、入力キーワードに近い候補を提案して検索のやり直しを促す UX パターン。部分一致による候補抽出とワンクリック再検索の実装例。

nextjssearch-filterui-component

対応バージョン

nextjs 15react 19

前提環境

React の基本的なコンポーネント実装と URL クエリパラメータの操作を理解していること

概要

検索結果が 0 件のとき、入力キーワードで全データのタイトル・タグを部分一致検索して関連候補を抽出し、ワンクリックで再検索できるサジェスト UI を表示する。Server Component でデータを渡し、候補抽出ロジックを純粋関数として分離する。

インストール

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

実装

候補抽出の純粋関数

// lib/suggestions.ts

export type SuggestionItem = {
  label: string;
  query: string;
};

/**
 * query のトークンを各サンプルのタイトル・タグと部分一致させて候補を抽出する。
 * 最大 maxCount 件を返す。
 */
export function buildSuggestions(
  samples: { title: string; tags: string[] }[],
  query: string,
  maxCount = 5
): SuggestionItem[] {
  if (!query.trim()) return [];

  const q = query.toLowerCase();
  const seen = new Set<string>();
  const results: SuggestionItem[] = [];

  for (const sample of samples) {
    // タグに一致するものをまず集める
    for (const tag of sample.tags) {
      if (tag.includes(q) && !seen.has(tag)) {
        seen.add(tag);
        results.push({ label: tag, query: tag });
        if (results.length >= maxCount) return results;
      }
    }
  }

  // タグで足りなければタイトルの先頭単語を追加
  for (const sample of samples) {
    const word = sample.title.split(/[\s・]/)[0];
    if (word.toLowerCase().includes(q) && !seen.has(word)) {
      seen.add(word);
      results.push({ label: word, query: word });
      if (results.length >= maxCount) return results;
    }
  }

  return results;
}

サジェストコンポーネント

// components/NoResultsSuggestion.tsx
import Link from "next/link";

type Props = {
  query: string;
  suggestions: { label: string; query: string }[];
  resetHref?: string;
};

export function NoResultsSuggestion({ query, suggestions, resetHref = "/samples" }: Props) {
  return (
    <div className="flex flex-col items-center gap-4 py-16 text-center">
      <p className="text-lg font-semibold text-gray-700">
        「{query}」に一致するサンプルは見つかりませんでした
      </p>

      {suggestions.length > 0 && (
        <div>
          <p className="mb-2 text-sm text-gray-500">こちらのキーワードはいかがですか?</p>
          <div className="flex flex-wrap justify-center gap-2">
            {suggestions.map((s) => (
              <Link
                key={s.query}
                href={`/samples?q=${encodeURIComponent(s.query)}`}
                className="rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-sm text-blue-700 hover:bg-blue-100"
              >
                {s.label}
              </Link>
            ))}
          </div>
        </div>
      )}

      <Link href={resetHref} className="text-sm text-gray-400 hover:underline">
        検索条件をリセット
      </Link>
    </div>
  );
}

一覧ページで 0 件時に使う

// app/samples/page.tsx(抜粋)
import { buildSuggestions } from "@/lib/suggestions";
import { NoResultsSuggestion } from "@/components/NoResultsSuggestion";

export default async function SamplesPage({ searchParams }: Props) {
  const params = await searchParams;
  const q = typeof params.q === "string" ? params.q : "";

  const allSamples = await getAllSamples();
  const filtered = filterSamples(allSamples, { q });

  if (filtered.length === 0 && q) {
    const suggestions = buildSuggestions(allSamples, q);
    return (
      <NoResultsSuggestion
        query={q}
        suggestions={suggestions}
        resetHref="/samples"
      />
    );
  }

  return (
    <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
      {filtered.map((sample) => (
        <SampleCard key={sample.slug} sample={sample} query={q} />
      ))}
    </div>
  );
}

ポイント

  • buildSuggestions は pure function として分離する。allSamplesquery だけを受け取り、DOM やルーターに依存しないのでユニットテストが容易
  • タグからの候補を優先し、足りなければタイトルの先頭単語を追加する 2 段階抽出にすることで、意味的に近い候補が上位に来やすい
  • seen セットで重複候補を排除する。同じキーワードが複数サンプルに含まれていても 1 回だけ提案する
  • 候補チップは <Link href="/samples?q=..."><a> タグとして実装する。Client Component 不要でサーバーレンダリングでき、ページ遷移も通常のリンクで実現できる
  • q が空のときは候補を返さず EmptyState に委譲する。サジェストは「キーワード入力 + 0 件」に限定することで表示条件を明確にする
  • maxCount のデフォルト 5 は画面幅に収まる上限の目安。多すぎるとユーザーが選択に迷うため絞り込む

注意点

react-clear-search-button は入力クリア。tailwind-empty-state-ui は 0 件時の汎用表示。これは 0 件のとき既存データから部分一致で候補を抽出してワンクリック再検索させる提案 UI に特化。

関連サンプル