Reactで、入力が止まるまで検索を待つカスタムフックの作り方
検索欄に1文字打つたびにサーバーへ問い合わせると、打ち終わるまでに何件ものリクエストが飛びます。入力が止まるまで待ってから検索するカスタムフックを作ると、検証では「しろたま」の4文字を打つあいだのリクエストが4件から1件になりました。検索欄のコンポーネントの側では、フックを呼ぶ1行で済みます。
フックには、待つ処理のほかに、古いリクエストの取り消し、空欄のときと検索中の表示、検索する関数の受け取り方を入れました。日本語の変換中に検索が走らないようにする書き方も、あわせて確かめています。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chrome for Testing 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.56.1 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で操作したブラウザで動かしました。検索の API は、猫の名前を部分一致で探す、検証用に Node.js で作ったサーバーです。日本語の変換は、本物の IME ではなく、Chrome DevTools Protocol で再現しました。変換中の文字は Input.imeSetComposition、確定は Input.insertText で送っています。Firefox と Safari では試していません。
待つ部分は useDebouncedValue に分ける
入力が止まるのを待つ部分は、値を受け取り、その値が一定の時間変わらなかったら返すフックにします。値が変わるたびにエフェクトの片付け関数でタイマーを消すので、打ち続けているあいだは古い値のままです。
import { useEffect, useState } from 'react';
// value が delayMs のあいだ変わらなかったら、その値を返す
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}
useEffect のリファレンスのとおり、依存配列の値が変わると、React は前の片付け関数を呼んでから、新しい値でエフェクトを動かします。このフックを renderHook でテストする方法は、以前の記事「Reactのカスタムフックを切り出してrenderHookでテストする方法」で書きました。
検索まで受け持つ useDebouncedSearch
useDebouncedValue で確定した語を使って、検索の関数を呼び、結果を返すフックです。検索の関数は、使う側から受け取ります。
import { useEffect, useEffectEvent, useState } from 'react';
import { useDebouncedValue } from './useDebouncedValue';
export type SearchStatus = 'idle' | 'pending' | 'done' | 'error';
type Options = { delayMs?: number; minLength?: number };
// 入力が delayMs のあいだ止まったら search を呼び、結果を返す
export function useDebouncedSearch<T>(
text: string,
search: (query: string, signal: AbortSignal) => Promise<T[]>,
{ delayMs = 300, minLength = 1 }: Options = {},
) {
const trimmed = text.trim();
const query = useDebouncedValue(trimmed, delayMs);
const [result, setResult] = useState<{ query: string; items: T[]; error: unknown }>({
query: '',
items: [],
error: null,
});
// 使う側が描画のたびに新しい関数を渡しても、エフェクトを動かし直さない
const runSearch = useEffectEvent(search);
useEffect(() => {
if (query.length < minLength) return;
const controller = new AbortController();
runSearch(query, controller.signal).then(
(items) => {
if (!controller.signal.aborted) setResult({ query, items, error: null });
},
(error) => {
if (!controller.signal.aborted) setResult({ query, items: [], error });
},
);
// 次の語で検索し直すときや、画面から消えるときに、前のリクエストを取り消す
return () => controller.abort();
}, [query, minLength]);
if (trimmed.length < minLength) return { status: 'idle' as SearchStatus, items: [] as T[], query: '' };
const status: SearchStatus =
result.query !== trimmed ? 'pending' : result.error ? 'error' : 'done';
return { status, items: result.items, query: result.query };
}
検索欄のコンポーネントは、入力欄の文字を useState で持ち、フックに渡すだけです。状態の表示に使う STATUS_LABELS は、pending のときは「入力待ち・検索中…」、error のときは「検索できませんでした」と出し、それ以外は何も出さない、検証用の対応表です。検索の関数 searchCats は、fetch に signal を渡して /api/cats?q=… に問い合わせ、JSON を返します。
const STATUS_LABELS = { idle: '', pending: '入力待ち・検索中…', done: '', error: '検索できませんでした' };
export function CatSearch({ delayMs = 300 }: { delayMs?: number }) {
const [text, setText] = useState('');
const { status, items, query } = useDebouncedSearch(text, searchCats, { delayMs });
return (
<div>
<input aria-label="猫の名前で検索" data-testid="q" value={text} onChange={(e) => setText(e.target.value)} />
<p data-testid="status">{STATUS_LABELS[status]}</p>
{status !== 'idle' && (
<ul data-testid="results" data-query={query} style={{ opacity: status === 'pending' ? 0.5 : 1 }}>
{items.map((cat) => (
<li key={cat.id}>{cat.name}</li>
))}
</ul>
)}
</div>
);
}
サーバーが受けるリクエストは1件になる
「しろたま」を100ミリ秒おきに1文字ずつ入力し、1秒待ってから、サーバーが受けたリクエストを数えました。比べる相手は、同じフックで待ち時間を0にしたものです。出力の nodelay の行が待ち時間0、hook の行が300ミリ秒待つこの記事のフックです。
nodelay: サーバーが受けたリクエスト 4 件: し(aborted) しろ(aborted) しろた(aborted) しろたま(done)
表示: data-query="しろたま" [しろたま]
hook: サーバーが受けたリクエスト 1 件: しろたま(done)
表示: data-query="しろたま" [しろたま]
待ち時間が0のときは、1文字ごとにリクエストが出ました。そのうち3件は次の文字で取り消されたので表示は正しいのですが、サーバーには4件とも届いています。300ミリ秒待つフックでは、リクエストは「しろたま」の1件だけでした。
待つのは「打ち終わるまで」ではなく、「手が止まってから300ミリ秒」です。下の動画の、入力の2回目のように「み」で手を止めると、その時点で「み」を検索します。

古い語の結果で、表示が上書きされない
エフェクトの片付け関数で abort() を呼んでいるので、検索し直すときには前のリクエストが取り消されます。検証用のサーバーで「み」の検索だけ1.5秒かかるようにし、「み」を検索したあとに「けこ」を足しました。取り消しを書かずに、届いた結果をそのまま表示する書き方と比べた結果です。出力の noabort の行が取り消さない書き方、hook の行がこの記事のフックです。
noabort: 「みけこ」まで入力して 800ms 後の表示: data-query="みけこ" [みけこ]
noabort: さらに 1500ms 後の表示: data-query="み" [みけ, みけこ] 入力欄「みけこ」
リクエスト: み(done) みけこ(done)
hook: 「みけこ」まで入力して 800ms 後の表示: data-query="みけこ" [みけこ]
hook: さらに 1500ms 後の表示: data-query="みけこ" [みけこ] 入力欄「みけこ」
リクエスト: み(aborted) みけこ(done)
取り消さない書き方では、あとから届いた「み」の結果で一覧が上書きされ、入力欄は「みけこ」なのに「みけ」も表示されました。入力が止まるまで待っても、そのあとに送った検索どうしでは、返事が届く順番が入れ替わることがあります。フックでは「み」のリクエストが取り消され、「みけこ」の結果が残りました。
fetch は、取り消されると AbortError で失敗します(MDN の fetch() の説明)。フックは、成功でも失敗でも controller.signal.aborted を見てから結果を書き込むので、取り消しをエラーとして表示しません。aborted を見てから書き込むので、signal を見ない検索の関数を渡されても、取り消したあとに届いた結果は書き込みません。ただし、その場合はリクエスト自体は取り消されず、サーバーに届きます。取り消しの仕組みと AbortError の扱いは、以前の記事「ReactのuseEffectでのfetchの競合をAbortControllerで防ぐ方法」で詳しく書きました。
検索欄を画面から消したときも、同じ片付け関数が動きます。打ってすぐ消したときは、タイマーが消えてリクエストは0件でした。検索が始まってから消したときは、そのリクエストが取り消されました(さば(aborted))。
空欄と検索中の表示は、状態を足さずに決める
フックが返す status は、useState を増やさず、描画のたびに今の入力と最後に届いた結果の語を比べて決めています。入力が minLength より短ければ idle で、結果は空の配列です。入力と結果の語が違えば pending です。打っている途中も、検索の返事を待っているあいだも、ここに入ります。
入力直後: 状態「入力待ち・検索中…」 リクエスト 0 件
止まって 350ms: 状態「入力待ち・検索中…」 リクエスト 1 件: くろ(pending)
止まって 750ms: 状態「」 表示: data-query="くろ" [くろ, くろまめ]
入力欄を空にして 500ms: 状態「」 表示: (一覧なし) リクエスト 1 件
入力欄を空にすると、リクエストは出ずに一覧が消えました。pending のあいだは、前の語の結果を薄くして残します。一度結果が出たあとは、打ち足しても一覧が消えないので、画面が跳ねません。動画のように、入力欄を空にすると一覧はいったん消えますが、別の語を打つと前の語の結果が薄く表示され、新しい結果が届くと入れ替わります。
検索の関数は、useEffectEvent で受け取る
検索の関数を依存配列に入れると、使う側が (q, signal) => searchCats(q, signal) のように描画のたびに新しい関数を渡したとき、問題が起きます。結果が届いて描画し直すたびに関数が変わり、エフェクトがまた検索します。「くろ」と打って2秒待つと、依存配列に入れる書き方では、同じ「くろ」のリクエストが繰り返し送られました。出力の deps の行が依存配列に入れる書き方、inline の行が、この記事のフックに同じように新しい関数を渡したものです。
deps: サーバーが受けたリクエスト 9 件(最初の3件: くろ(done) くろ(done) くろ(done))
表示: data-query="くろ" [くろ, くろまめ]
inline: サーバーが受けたリクエスト 1 件(最初の3件: くろ(done))
表示: data-query="くろ" [くろ, くろまめ]
useDebouncedSearch は、受け取った関数を useEffectEvent で包み、エフェクトの中から呼んでいます。そのため、描画のたびに新しい関数を渡しても、リクエストは1件でした。useEffectEvent のリファレンスにある、カスタムフックでの使い方でも、受け取ったコールバックを同じように包んでいます。
ただし、同じページの注意事項には、依存配列に書くべき値を隠すために使わないこととあります。カテゴリーなど、変わったら検索し直したい条件は、search の中に閉じ込めず、フックに渡す値に含めます。
useEffect のリファレンスの注意事項にあるとおり、StrictMode では、開発中、マウントのときにエフェクトのセットアップと片付けが1回余分に動きます。このフックでは、マウントのときは入力が空なので、検索は送られません。開発用のビルドで StrictMode にして「みけ」と打ったときも、リクエストは1件でした。
日本語の変換中は、確定してから検索する
日本語の変換中にも、入力欄には input イベントが届き、React の onChange が呼ばれます。変換中の文字を「k」「く」「くr」「くろ」と変え、「く」で500ミリ秒止めてから確定すると、フックは確定する前の「く」と「くろ」で検索しました。イベントの順番を記録すると、確定するときの最後の input も isComposing が true のまま届き、そのあとに compositionend が届きました。出力の hook の行が、この記事のフックをそのまま使った検索欄です。
compositionstart data=""
input data="k" isComposing=true value=k
input data="く" isComposing=true value=く
input data="くr" isComposing=true value=くr
input data="くろ" isComposing=true value=くろ
hook: 確定する前のリクエスト: く(done) くろ(pending)
input data="くろ" isComposing=true value=くろ
compositionend data="くろ"
InputEvent.isComposing は、compositionstart のあとで compositionend の前に起きたイベントかどうかを表します。そこで、入力欄に表示する文字と、検索に渡す文字を分けました。変換中は検索に渡す文字を変えず、compositionend で確定した文字を渡します。最後の input も変換中として届くので、onCompositionEnd が欠かせません。
export function CatSearchIme() {
const [text, setText] = useState('');
const [committed, setCommitted] = useState('');
const { status, items, query } = useDebouncedSearch(committed, searchCats);
return (
<div>
<input
aria-label="猫の名前で検索"
data-testid="q"
value={text}
onChange={(e) => {
setText(e.target.value);
if (!(e.nativeEvent as InputEvent).isComposing) setCommitted(e.target.value);
}}
onCompositionEnd={(e) => setCommitted(e.currentTarget.value)}
/>
状態と一覧を表示する部分は CatSearch と同じなので、入力欄までを抜き出しました。
この書き方(出力の ime の行)では、同じ操作をしても確定する前のリクエストはなく、確定したあとの「くろ」の1件だけになりました。この結果は Input.imeSetComposition で再現した変換で確かめたもので、実際の IME やブラウザによって、イベントの順番が違う可能性があります。
ime: 確定する前のリクエスト: なし
ime: 確定して 600ms 後のリクエスト: くろ(done) 表示: data-query="くろ" [くろ, くろまめ]
フックにまとめると、検索欄が気にすることが減る
useDebouncedSearch を使う検索欄は、入力欄の文字を持ってフックに渡し、返ってきた status と items を表示するだけです。待つ時間、古いリクエストの取り消し、空欄の扱い、描画のたびに変わる関数への備えは、フックの中で決まっています。打ち続けても検索は手が止まってからの1回で、表示は今の入力に合った結果だけになります。日本語の入力が多い画面では、変換が確定してから検索に渡す文字を変えると、変換中の読みで検索することもなくなります。





