Reactで、検索欄に前回の検索語を候補として出す方法
検索した語を localStorage に新しい順で残しておくと、次に検索欄を開いたときに、前回までの検索語を候補として出せます。作るときに気をつけたいのは、一覧を閉じるタイミングとフォーカスの行き先です。一覧は、候補へフォーカスが移っても閉じず、検索フォームの外へ出たときに閉じます。候補を押したあとは、フォーカスを入力欄に戻します。こうすると、マウスでもキーボードでも候補を選べます。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、@types/react 19.3.0、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright でヘッドレスの Chromium を操作しました。Firefox と Safari、スマートフォンのタッチ操作、日本語入力の変換中の操作、スクリーンリーダーでの実際の読み上げ、サーバーでの描画(SSR)は試していません。

履歴は「先頭に足し、同じ語を外し、5件で切る」
履歴の扱いは、React に関係のない関数にまとめました。addTerm は、検索した語を配列の先頭に入れます。同じ語が前からあればその位置から外し、件数は5件までにします。前後の空白は取り、空白だけの語は足しません。
export const STORAGE_KEY = 'search-history';
export const MAX_HISTORY = 5;
// 新しい語を先頭に入れる。同じ語は前の位置から外し、件数は MAX_HISTORY まで
export function addTerm(history: string[], term: string): string[] {
const t = term.trim();
if (t === '') return history;
return [t, ...history.filter((h) => h !== t)].slice(0, MAX_HISTORY);
}
// 読めない値や壊れた値は、履歴なしとして扱う
export function loadHistory(): string[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return [];
const value: unknown = JSON.parse(raw);
if (!Array.isArray(value)) return [];
return value.filter((v): v is string => typeof v === 'string').slice(0, MAX_HISTORY);
} catch {
return [];
}
}
export function saveHistory(history: string[]): void {
try {
if (history.length === 0) localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
} catch {
// 保存できなくても、検索そのものは止めない
}
}
検証環境で「猫 ごはん」「猫 爪とぎ」「キャットタワー」の順に検索してから、前後に空白を付けた「 猫 ごはん 」で検索しました。「猫 ごはん」は2件にならず、先頭に移りました。空白だけで Enter キーを押したときは、履歴は変わりませんでした。
「 猫 ごはん 」(前後に空白)を入力して Enter
→ 入力欄「猫 ごはん」 | 候補なし(閉じている) | フォーカス: 入力欄 | 「猫 ごはん」で検索しました | 保存: ["猫 ごはん","キャットタワー","猫 爪とぎ"]
空白だけを入力して Enter
→ 入力欄「 」 | 候補[猫 ごはん / キャットタワー / 猫 爪とぎ] | フォーカス: 入力欄 | 「猫 ごはん」で検索しました | 保存: ["猫 ごはん","キャットタワー","猫 爪とぎ"]
「猫砂」「猫 おもちゃ」「猫 ベッド」を続けて検索し、入力欄を空にする
→ 入力欄「」 | 候補[猫 ベッド / 猫 おもちゃ / 猫砂 / 猫 ごはん / キャットタワー] | フォーカス: 入力欄 | 「猫 ベッド」で検索しました | 保存: ["猫 ベッド","猫 おもちゃ","猫砂","猫 ごはん","キャットタワー"]
このログは、テスト用のスクリプトで画面と localStorage を読んで書き出したものです。「候補[…]」は画面に出ている候補、「保存:」のあとは localStorage に入っている文字列です。6件目を足したところで、いちばん古い「猫 爪とぎ」が外れました。
localStorage の読み書きは、失敗しても検索を止めないように try〜catch で包んでいます。MDN によると、localStorage を読むときは、ブラウザの設定などで保存が許されていないと SecurityError が投げられます。setItem は、容量が足りないと QuotaExceededError を投げます。検証環境では、あらかじめ保存しておく値を、JSON でない文字列、オブジェクト、文字列以外がまじった配列の3通りにして、ページを開きました。どれもページのエラーは出ず、文字列の語だけが候補に出ました。
保存されていた値 not json で開いて入力欄にフォーカス(ページのエラー: 0件)
→ 入力欄「」 | 候補なし(閉じている) | フォーカス: 入力欄 | まだ検索していません | 保存: not json
保存されていた値 {"term":"猫"} で開いて入力欄にフォーカス(ページのエラー: 0件)
→ 入力欄「」 | 候補なし(閉じている) | フォーカス: 入力欄 | まだ検索していません | 保存: {"term":"猫"}
保存されていた値 [1,"猫 ごはん",null,"猫砂"] で開いて入力欄にフォーカス(ページのエラー: 0件)
→ 入力欄「」 | 候補[猫 ごはん / 猫砂] | フォーカス: 入力欄 | まだ検索していません | 保存: [1,"猫 ごはん",null,"猫砂"]
フックでは、書く直前に localStorage を読み直す
履歴を React の state として持つのが、useSearchHistory です。最初の値は、useState に loadHistory を関数のまま渡して読みます。公式リファレンスの初期値を毎回作り直さない書き方のとおり、こうすると読み込みは最初の描画のときだけになります。
import { useState } from 'react';
import { addTerm, loadHistory, saveHistory } from './history';
export function useSearchHistory() {
// 最初の描画のときに一度だけ localStorage から読む
const [history, setHistory] = useState<string[]>(loadHistory);
// 書くたびに localStorage から読み直してから変える。
// ほかのタブが足した語を、古い state で上書きしないため
function update(change: (current: string[]) => string[]) {
const next = change(loadHistory());
saveHistory(next);
setHistory(next);
}
return {
history,
add: (term: string) => update((h) => addTerm(h, term)),
remove: (term: string) => update((h) => h.filter((t) => t !== term)),
clear: () => update(() => []),
};
}
書くときに手元の state ではなく localStorage を読み直しているのは、同じサイトを2つのタブで開いている場合のためです。localStorage はオリジンごとに1つなので、タブ同士で同じ保存場所を使います。
比べるために、update の中の loadHistory() を history(手元の state)に変えた版も作りました。両方の版で、タブAとタブBを開いてから、タブAで「猫 ごはん」、タブBで「猫砂」、もう一度タブAで「猫 爪とぎ」を検索しました。state から計算する版では、タブBが書いたときにタブBが知らない「猫 ごはん」が消え、次にタブAが書いたときにタブAが知らない「猫砂」が消えました。
### 2つのタブで検索する:書く前に localStorage を読み直す版(useSearchHistory)
タブBで「猫砂」を検索
→ 入力欄「猫砂」 | 候補なし(閉じている) | フォーカス: 入力欄 | 「猫砂」で検索しました | 保存: ["猫砂","猫 ごはん"]
タブAで「猫 爪とぎ」を検索し、入力欄を空にする
→ 入力欄「」 | 候補[猫 爪とぎ / 猫砂 / 猫 ごはん] | フォーカス: 入力欄 | 「猫 爪とぎ」で検索しました | 保存: ["猫 爪とぎ","猫砂","猫 ごはん"]
### 2つのタブで検索する:state から計算して書く版
タブBで「猫砂」を検索
→ 入力欄「猫砂」 | 候補なし(閉じている) | フォーカス: 入力欄 | 「猫砂」で検索しました | 保存: ["猫砂"]
タブAで「猫 爪とぎ」を検索し、入力欄を空にする
→ 入力欄「」 | 候補[猫 爪とぎ / 猫 ごはん] | フォーカス: 入力欄 | 「猫 爪とぎ」で検索しました | 保存: ["猫 爪とぎ","猫 ごはん"]
読み直す版でも、ほかのタブが書いた語が、開いているタブの候補にすぐ出るわけではありません。検証環境では、タブBが書いた「猫砂」は、タブAで次に検索したときに候補に入りました。すぐに出したい場合は、ほかのタブが localStorage を変えたときに届く storage イベントを受け取って state を読み直す方法がありますが、この記事では試していません。
一覧は、フォーカスが form の外へ出たときに閉じる
検索欄の部品が SearchBox です。入力欄にフォーカスすると候補の一覧を開き、Enter キーか候補のボタンで検索します。候補の横の × で1件ずつ、下のボタンで全部を消せます。
import { useRef, useState } from 'react';
import { useSearchHistory } from './useSearchHistory';
type Props = { onSearch: (term: string) => void };
export function SearchBox({ onSearch }: Props) {
const { history, add, remove, clear } = useSearchHistory();
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// 入力中の文字を含む履歴だけを出す。空欄なら全部
const q = query.trim();
const suggestions = history.filter((term) => term.includes(q) && term !== q);
// 押したボタンは閉じた一覧と一緒に消えるので、フォーカスを入力欄に戻す
function focusInput() {
inputRef.current?.focus();
}
function search(term: string) {
const t = term.trim();
if (t === '') return;
focusInput();
add(t);
setQuery(t);
setOpen(false);
onSearch(t);
}
function removeTerm(term: string) {
remove(term);
focusInput();
}
function clearAll() {
clear();
focusInput();
}
return (
<form
role="search"
className="search"
onSubmit={(e) => {
e.preventDefault();
search(query);
}}
// フォーカスが form の外へ出たときだけ閉じる。候補のボタンへ移るときは閉じない
onFocus={() => setOpen(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setOpen(false);
}}
onKeyDown={(e) => {
if (e.key === 'Escape') setOpen(false);
}}
>
<input
ref={inputRef}
type="search"
aria-label="検索語"
autoComplete="off"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
/>
<button type="submit">検索</button>
{open && suggestions.length > 0 && (
<section className="history" aria-label="最近の検索">
<ul>
{suggestions.map((term) => (
<li key={term}>
<button type="button" className="term" onClick={() => search(term)}>
{term}
</button>
<button
type="button"
className="remove"
aria-label={`「${term}」を履歴から消す`}
onClick={() => removeTerm(term)}
>
×
</button>
</li>
))}
</ul>
<button type="button" className="clear" onClick={clearAll}>
履歴をすべて消す
</button>
</section>
)}
</form>
);
}
onFocus と onBlur は、入力欄ではなく form に付けています。React の onFocus と onBlur は、ブラウザの focus・blur イベントと違って、子の要素で起きたものも親に伝わります(公式リファレンスの onBlur の説明)。onBlur の relatedTarget は、次にフォーカスを受け取る要素です(ない場合は null。MDN の relatedTarget の説明)。それが form の中にあれば、一覧は開いたままにします。公式リファレンスにも、currentTarget と relatedTarget で、フォーカスが親の要素の外から来たか・外へ出たかを見分ける例があります。
比べるために、入力欄の onBlur で、フォーカスが外れたらすぐ閉じる版も作りました。× や「履歴をすべて消す」、Esc キーの処理、フォーカスを戻す処理は省いた簡略版です。
<input
type="search"
aria-label="検索語"
autoComplete="off"
value={query}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)} // 入力欄からフォーカスが外れたら、すぐ閉じる
/>
両方の版で、候補「猫 爪とぎ」のある位置で、マウスのボタンを押してから離しました。入力欄の onBlur で閉じる版では、ボタンを押した時点で一覧が閉じ、離しても検索されませんでした。SearchBox では、押した時点でフォーカスが候補のボタンに移り、一覧は開いたままでした。
### マウスで候補を押す:form の onBlur で、フォーカスが form の外へ出たときだけ閉じる版(SearchBox)
候補「猫 爪とぎ」の位置でマウスのボタンを押す(まだ離さない)
→ 入力欄「」 | 候補[猫 ごはん / 猫 爪とぎ / キャットタワー] | フォーカス: 猫 爪とぎ | まだ検索していません | 保存: ["猫 ごはん","猫 爪とぎ","キャットタワー"]
マウスのボタンを離す
→ 入力欄「猫 爪とぎ」 | 候補なし(閉じている) | フォーカス: 入力欄 | 「猫 爪とぎ」で検索しました | 保存: ["猫 爪とぎ","猫 ごはん","キャットタワー"]
### マウスで候補を押す:入力欄の onBlur ですぐ閉じる版
候補「猫 爪とぎ」の位置でマウスのボタンを押す(まだ離さない)
→ 入力欄「」 | 候補なし(閉じている) | フォーカス: body(どこにもない) | まだ検索していません | 保存: ["猫 ごはん","猫 爪とぎ","キャットタワー"]
マウスのボタンを離す
→ 入力欄「」 | 候補なし(閉じている) | フォーカス: body(どこにもない) | まだ検索していません | 保存: ["猫 ごはん","猫 爪とぎ","キャットタワー"]
キーボードでも差が出ました。入力欄から Tab キーを2回押すと、SearchBox では「検索」ボタンを経て最初の候補に移り、Enter キーでその語を検索できました。入力欄の onBlur で閉じる版では、1回目の Tab キーで一覧が閉じ、候補にはたどり着けませんでした(ログは抜粋)。
### キーボードで候補を選ぶ:form の onBlur で、フォーカスが form の外へ出たときだけ閉じる版(SearchBox)
Tab
→ 入力欄「」 | 候補[猫 爪とぎ / 猫 ごはん / キャットタワー] | フォーカス: 猫 爪とぎ | まだ検索していません | 保存: ["猫 爪とぎ","猫 ごはん","キャットタワー"]
Enter
→ 入力欄「猫 爪とぎ」 | 候補なし(閉じている) | フォーカス: 入力欄 | 「猫 爪とぎ」で検索しました | 保存: ["猫 爪とぎ","猫 ごはん","キャットタワー"]
### キーボードで候補を選ぶ:入力欄の onBlur ですぐ閉じる版
Tab
→ 入力欄「」 | 候補なし(閉じている) | フォーカス: 検索 | まだ検索していません | 保存: ["猫 ごはん","猫 爪とぎ","キャットタワー"]
MDN の button 要素の説明によると、ボタンをクリックしたときにフォーカスが移るかどうかはブラウザと OS によって違い、Safari では移りません。その場合、relatedTarget に候補のボタンが入らず、SearchBox でもクリックの前に一覧が閉じるおそれがあります。この記事の結果は Chromium だけのもので、Safari では試していません。
押したボタンが消えるときは、入力欄にフォーカスを戻す
候補を押して検索すると一覧は閉じ、押したボタンも画面から消えます。× で消した候補のボタンも同じです。SearchBox では、focusInput でフォーカスを入力欄に戻しています。
比べるために、focusInput の中の inputRef.current?.focus(); の1行を消した版も作りました。「猫 爪とぎ」の × にフォーカスを置いて Enter キーを押すと、フォーカスのある要素がなくなりました(document.activeElement が body)。マウスで候補を押して検索したときも同じでした。SearchBox では、どちらもフォーカスが入力欄に戻り、そのまま次の語を入力できます。
### キーボードで履歴を1件消す:押したあとに入力欄へフォーカスを戻す版(SearchBox)
Enter
→ 入力欄「」 | 候補[猫 ごはん / キャットタワー] | フォーカス: 入力欄 | まだ検索していません | 保存: ["猫 ごはん","キャットタワー"]
### キーボードで履歴を1件消す:フォーカスを戻さない版
Enter
→ 入力欄「」 | 候補[猫 ごはん / キャットタワー] | フォーカス: body(どこにもない) | まだ検索していません | 保存: ["猫 ごはん","キャットタワー"]
search の中では、setOpen(false) より前に focusInput() を呼んでいます。候補のボタンから入力欄へフォーカスが移ると、form の onFocus が setOpen(true) を呼びます。同じイベントの中の state の更新はまとめて処理され、最後に呼んだ setOpen(false) が残るので、一覧は閉じます。検証環境でも、マウスで候補を押したあとは、フォーカスが入力欄にあり、一覧は閉じていました。
入力中の文字で候補を絞り、ブラウザの入力履歴は出さない
候補は、入力中の文字を含む語だけにしています。入力欄が空なら、履歴を全部出します。入力欄の文字とまったく同じ語は、候補から外しました。選んでも入力欄の文字が変わらないからです。「猫 」と入力したときは、「キャットタワー」が候補から外れました。
「猫 」と入力
→ 入力欄「猫 」 | 候補[猫 ベッド / 猫 おもちゃ / 猫砂 / 猫 ごはん] | フォーカス: 入力欄 | 「猫 ベッド」で検索しました | 保存: ["猫 ベッド","猫 おもちゃ","猫砂","猫 ごはん","キャットタワー"]
入力欄には autoComplete="off" を付けて、自分で出す一覧とブラウザの一覧が重ならないようにしています。MDN の autocomplete 属性の説明によると、ブラウザが出す入力の候補は、多くの場合、利用者が以前に入力した値です。off は、ページが自分で入力の候補を出す場合などに使う値とされています。ブラウザの一覧が実際に出なくなるかは、検証環境(ヘッドレスの Chromium)では確かめていません。
Playwright で書き出したアクセシビリティツリーでは、form は search(検索のランドマーク)になり、候補の一覧は「最近の検索」という名前の領域に入っていました(抜粋)。role="search" は、MDN の search ロールの説明にある使い方です。
- main:
- search:
- searchbox "検索語"
- button "検索"
- region "最近の検索":
- list:
- listitem:
- button "猫 ごはん"
- button "「猫 ごはん」を履歴から消す": ×
この部品では、候補はふつうのボタンで、Tab キーで順に移ります。↑↓キーで候補を選ぶ形にしたい場合は、WAI-ARIA の作り方のガイド(APG)にあるコンボボックスのパターンに沿って作り直すことになります。
履歴は、ブラウザに残り続けます。MDN によると、localStorage のデータには有効期限がありません。共用の端末で使われる画面なら、「履歴をすべて消す」のようなボタンを用意しておくと、利用者が自分で履歴を消せます。この部品では、押すと localStorage から履歴のキーごと消えます。
「履歴をすべて消す」を押す
→ 入力欄「」 | 候補なし(閉じている) | フォーカス: 入力欄 | まだ検索していません | 保存: null





