検索 Hook のレースコンディション — AbortController が効かない 3 つのパターンと、ガードが要る場所
インクリメンタルサーチのカスタム Hook で、古いリクエストの応答が新しい結果を上書きする問題を AbortController で潰す。ただし signal を渡しただけでは効かない。abort の呼び忘れ、finally の暴発、controller の再生成、エラー分岐でのガード漏れという典型的な壊れ方を順に見ていく。
検索ボックスに文字を打つたびに API を叩く UI を、React + TypeScript のカスタム Hook (useUserSearch) で書く。要件はこの 5 つ。
- 入力を 300ms デバウンスしてから API を叩く
fetchでユーザー一覧を取得する- ローディング / エラー / 0 件を出し分ける
- レースコンディション対策として
AbortControllerを使う - ロジックはすべて Hook に閉じる
厄介なのは 4 番目だ。AbortController は「使っている」ことと「効いている」ことが別で、その隙間に何種類ものバグが入る。しかもどれも画面上は正しく動いているように見える瞬間があるので、手元で軽く触っただけでは気づかない。
この記事では、signal を渡しただけの状態から始めて典型的な壊れ方を 3 つ潰し、最後に「stale チェックはどこに要るのか」を仕様と実験から整理する。
出発点
デバウンスして fetch するだけの素朴な形。
// hooks/useUserSearch.ts(出発点)
type User = { id: string; name: string; email: string }
export const useUserSearch = (query: string) => {
const [users, setUsers] = useState<User[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const timer = window.setTimeout(async () => {
setIsLoading(true)
const response = await fetch(`/api/users?q=${encodeURIComponent(query)}`)
const data: User[] = await response.json()
setUsers(data)
setIsLoading(false)
}, 300)
return () => window.clearTimeout(timer)
}, [query])
return { users, isLoading, error }
}デバウンスは効いている。しかしリクエストの完了順は保証されない。ab の応答が abc の応答より遅れて返ってくれば、画面には abc と入力されているのに ab の検索結果が表示される。これがレースコンディションだ。
ネットワークが速い環境では再現しにくく、実機や不安定な回線で初めて表面化する。だから「動いているように見える」で済ませてはいけない類のバグになる。
1. 「対策したつもり」の二段構え
最初の 2 つは、どちらもコードは書いてあるのに効いていないパターンだ。
1-a. signal を渡しただけで abort() を呼んでいない
// 1-a(バグあり)
useEffect(() => {
const controller = new AbortController()
const timer = window.setTimeout(async () => {
setIsLoading(true)
const response = await fetch(`/api/users?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
})
const data: User[] = await response.json()
setUsers(data)
setIsLoading(false)
}, 300)
return () => window.clearTimeout(timer)
}, [query])AbortController を作り、signal を fetch に渡している。それらしく見えるが、クリーンアップは clearTimeout しかしていない。
何が起きるか
abで入力が 300ms 止まり、fetch が飛ぶ- 続けて
abcと打つ →queryが変わり effect が再実行 → クリーンアップは timer を消すだけ abの fetch は誰にも止められず走り続けるabcの結果が先に返って描画される- 遅れて
abの結果が返り、setUsersで上書きする
signal を渡しただけでは何も起きない。controller.abort() を呼んで初めてリクエストが中断される。渡すのは配線で、abort() がスイッチだ。
return () => {
window.clearTimeout(timer)
controller.abort()
}教訓: 「対策コードが書いてある」と「対策が実行される」は別。まず呼び出し箇所を探す。
1-b. finally の setIsLoading(false) が中断後も走る
abort() を呼ぶと fetch は AbortError を投げるので、try/catch/finally で囲むことになる。ローディング解除は成功でも失敗でも必要だから finally に置きたくなる。ここが罠だ。
// 1-b(バグあり)
try {
setIsLoading(true)
const response = await fetch(url, { signal: controller.signal })
const data: User[] = await response.json()
setUsers(data)
} catch (e) {
if ((e as Error).name !== 'AbortError') {
setError('通信に失敗した')
}
} finally {
setIsLoading(false) // ← 中断されたリクエストでも必ず通る
}catch では AbortError を弾いている。そこだけ見ると安全に見えるが、finally は素通しになっている。
何が起きるか
abの fetch が飛ぶ →isLoadingがtrueabcに変わる →abが abort され、abcの fetch が飛ぶ →isLoadingはtrueのまま- abort された
ab側のcatchが走る(setErrorはスキップされる。ここは正しい) - そのまま
ab側のfinallyが走り、setIsLoading(false) abcはまだ飛んでいる最中なのに、スピナーが消える
finally は 成功・失敗・中断のすべてで必ず実行される。仕様通りの挙動だが、「エラーは catch で弾いたから安全」という前提と噛み合わない。catch にガードを書くなら finally にも同じガードが要る。
教訓: finally は「後片付け」ではなく「無条件実行」。中断されうる非同期処理では state 更新を置かない。
2. 判定の基準が 2 つに割れる
finally にガードを入れる、つまり「このリクエストが最新のときだけ state を触る」判定が必要になる。最新の controller を useRef に持って比較するのが素直な方針だが、出口ごとに書いていくと基準が揃わなくなる。
// 2(バグあり)
if (controllerRef.current === controller) {
setUsers(data)
}
// ...
if (!controller.signal.aborted) {
setIsLoading(false)
}片方は「ref が自分を指しているか」、もう片方は「自分は中断されていないか」を聞いている。同じことを確認しているつもりでも、検知できる範囲は違う。
| 判定 | 検知できること | 見落とすこと |
|---|---|---|
controllerRef.current === controller | 次の実行が始まって ref が差し替わったか | 次の実行より前に単独で abort されたこと(cleanup で abort した直後など) |
!controller.signal.aborted | 自分が中断されたか | abort を伴わずに ref だけ差し替わったこと |
abort() を呼ぶ場所と ref を差し替える場所が離れているほど、この 2 つはズレる。effect のクリーンアップで abort() し、300ms 後のデバウンス明けに ref を差し替える構成なら、中断済みなのに ref はまだ自分を指している時間帯が毎回発生する。そこでは前者が「自分は最新」と答えてしまう。
判定基準が 2 つあると、片方だけ通る隙間が生まれる。しかも「どちらが緩いか」は実装の配置しだいで変わるので、コードを読んでも追えない。
条件を 1 つに収束させる
// 2(修正)
const isLatest = () => controllerRef.current === controller
try {
const response = await fetch(url, { signal: controller.signal })
const data: User[] = await response.json()
if (!isLatest()) return
setUsers(data)
setIsLoading(false)
} catch {
if (!isLatest()) return
setError('通信に失敗した')
setIsLoading(false)
}finally をやめ、すべての出口で同じ isLatest() を通してから state を触る。判定の基準は 1 つだけにする。
そのうえで、上のズレ自体は abort() と ref の差し替えを同じ 2 行に隣接させることで消える。「前の実行を止めてから、自分を最新として登録する」を 1 箇所にまとめれば、「ref が自分を指していない」と「自分は中断された」が常に同時に成立するからだ。こうなると基準はどちらでもよくなる。
さらに、この ref 比較は controller.signal.aborted で置き換えられる。controller は各実行のローカル変数なので、「自分の signal が中断されたか」を聞けば同じ判定になり、ref を経由しなくて済む。最終形ではこちらを採る。
教訓: 判定したいのは「ref の現在値がどれか」ではなく「自分自身が最新か」。主語を自分に置くと条件が 1 つに収束する。
3. controller をレンダーのたびに作り直す
fetch の呼び出しを useCallback に切り出すとき、controller の生成場所を間違えるパターン。
// 3(バグあり)
export const useUserSearch = (query: string) => {
// レンダーのたびに新しい controller ができる
const controller = new AbortController()
const search = useCallback(async (keyword: string) => {
const response = await fetch(url, { signal: controller.signal })
// ...
}, [])
useEffect(() => {
const timer = window.setTimeout(() => void search(query), 300)
return () => {
window.clearTimeout(timer)
controller.abort() // ← 何を abort しているのか定まらない
}
}, [query, search])
}controller はレンダーごとの使い捨てになっている。useCallback のクロージャが掴んでいるのは初回レンダーの controller、クリーンアップが触るのはそのレンダー時点の controllerで、両者が一致しない。
何が起きるか
- 初回レンダーで controller A が作られ、
searchが A を掴む ab入力 → 再レンダーで controller B が作られるsearch('ab')は A の signal で fetch するabc入力 → クリーンアップが B を abort する。A の fetch は生きている- abort が一度も効かず、1-a と同じ状態に戻る
構図は hover 遅延フックで useRef を選んだ理由 と同じだ。レンダーをまたいで同一性を保ちたい値をレンダー本体で作ると、生存期間がレンダー 1 回分になり、「前のもの」を指す手段が消える。
修正は useRef に載せること。
const controllerRef = useRef<AbortController | null>(null)
const search = useCallback(async (keyword: string) => {
controllerRef.current?.abort() // 前の実行を止める
const controller = new AbortController()
controllerRef.current = controller // 自分を最新として登録する
// ...
}, [])この 2 行がこの Hook の心臓部になる。useEffect のクリーンアップではなく search の先頭に置くことで、デバウンス済みの実行同士が直接バトンを渡す形になり、中断の責務が 1 箇所に寄る。
教訓: abort() が効かないときは、abort() を呼ぶ側と signal を渡した側が同じインスタンスかを疑う。
4. ガードはどこまで必要か
「await のたびに stale チェックを置く」という書き方をよく見る。だが signal を渡した fetch では、その多くは通らない。abort が例外として届くからだ。
Fetch の仕様では、abort されたときレスポンスボディのストリームは abort reason で error される。
If response body is non-null, then error response body's stream with error. — Fetch Standard, "abort fetch"
つまり読み取り中の response.json() は AbortError で reject する。ヘッダーだけ先に返してボディを遅らせるサーバーを立てて確かめた。
// ボディを 500ms 遅らせて返すサーバーに対して
const response = await fetch(url, { signal: controller.signal })
setTimeout(() => controller.abort(), 100) // ボディ読み取りの最中に中断する
try {
const body = await response.json()
console.log('解決', body)
} catch (e) {
console.log('reject', e.name) // → AbortError
}200 でも 500 でも結果は同じで、必ず reject 側に落ちる。ここから言えることは 2 つ。
tryの中に到達しているということは、その時点まで abort されていないということ- JavaScript は単一スレッドなので、
awaitのない区間に abort が割り込むこともない
したがって、こう書きたくなるガードは実際にはほぼ通らない。
const response = await fetch(url, { signal: controller.signal })
if (controller.signal.aborted) return // ← abort されていれば fetch 自体が reject している
if (!response.ok) {
setError(`検索に失敗した (${response.status})`) // ここも同様。到達時点で aborted はまず false
return
}置いても害はないが、これがあるから安全なわけではない。ガードが必要なのは、abort が例外として届かない場所だけだ。
| 場所 | ガード | 理由 |
|---|---|---|
await fetch / await response.json() の直後 | ほぼ不要 | abort されれば reject して catch に飛ぶ |
catch | 要る | AbortError を通常のエラーとして表示しないため |
finally | 要る | 中断でも無条件に実行される(1-b) |
signal を渡していない await の直後 | 要る | 中断が例外として届かない |
実務で刺さるのは 4 行目だ。fetch の後に、signal を知らない非同期処理を挟んだ場合。
// 4(バグあり)— signal を知らない await をまたぐ
const users: User[] = await response.json()
const enriched = await attachAvatars(users) // signal を渡していない
setUsers(enriched) // ← abort されても到達する何が起きるか
abのresponse.json()までは完了し、attachAvatarsの待ちに入る- その待ちのあいだに
abcが始まり、abの controller は abort される - しかし
attachAvatarsは signal を知らないので中断されない - 待ちが明けて
setUsers(enriched)が走り、abcの結果を古いabの結果で上書きする
fetch を中断したことと、その後続の処理が止まることは別だ。abort が効くのは signal が届く範囲までで、そこから先は自分で判定するしかない。
対処は、分岐や await ごとに判断するのをやめ、state を触る出口を 1 本に絞ること。
// 4(修正)— 出口を 1 つの関数に集約する
const commit = (next: SearchState) => {
if (controller.signal.aborted) return
setState(next)
}不要な場所でガードが走っても害はない。「この await は signal-aware か」を毎回判断するコストのほうが高いので、判断そのものを消す。
教訓: ガードが要るのは「abort が例外として届かない場所」。await の数ではなく、signal が届く範囲で考える。
最終形: 素の React / TypeScript
ここまでを踏まえた完成版。state を 1 つのオブジェクトにまとめ、出口を commit に集約している。
// hooks/useUserSearch.ts
import { useCallback, useEffect, useRef, useState } from 'react'
export type User = { id: string; name: string; email: string }
type SearchState = {
users: User[]
isLoading: boolean
error: string | null
}
const initialState: SearchState = { users: [], isLoading: false, error: null }
export const useUserSearch = (query: string, delayMs = 300) => {
const [state, setState] = useState<SearchState>(initialState)
const controllerRef = useRef<AbortController | null>(null)
const search = useCallback(async (keyword: string) => {
// 前の実行を止めてから、自分を最新として登録する
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
// 中断された実行が state を触らないための唯一の出口
const commit = (next: SearchState) => {
if (controller.signal.aborted) return
setState(next)
}
commit({ users: [], isLoading: true, error: null })
try {
const response = await fetch(`/api/users?q=${encodeURIComponent(keyword)}`, {
signal: controller.signal,
})
if (!response.ok) {
commit({ users: [], isLoading: false, error: `検索に失敗した (${response.status})` })
return
}
const users: User[] = await response.json()
commit({ users, isLoading: false, error: null })
} catch {
// abort 由来の例外は commit 側の signal チェックで落ちるので分岐は不要
commit({ users: [], isLoading: false, error: '通信に失敗した' })
}
}, [])
useEffect(() => {
const keyword = query.trim()
// 空文字は API を叩かず、進行中の検索も畳む
if (keyword === '') {
controllerRef.current?.abort()
controllerRef.current = null
setState(initialState)
return
}
const timer = window.setTimeout(() => void search(keyword), delayMs)
return () => window.clearTimeout(timer)
}, [query, delayMs, search])
useEffect(() => () => controllerRef.current?.abort(), [])
return state
}素朴な実装から変えた点と、その理由。
| 変更 | 理由 |
|---|---|
finally を使わない | 中断時も無条件で走るため(1-b) |
stale チェックを commit に集約 | 「この await は signal-aware か」を毎回判断せずに済む(4) |
controller を useRef に持つ | レンダーをまたいで同一インスタンスを指すため(3) |
signal.aborted で判定 | ref 比較と等価で、主語が「自分」になり読みやすい(2) |
catch で AbortError を判定しない | commit が弾くので分岐が 1 つ減る |
空文字ガードを useEffect 側に置く | 「叩かない」判断はデバウンス前に済ませたい |
| state を 1 オブジェクトにまとめる | isLoading だけ更新されて整合しない状態を作れなくする |
unmount 時に abort | アンマウント後の state 更新を防ぐ |
入力文字列の状態はどこに置くか
useUserSearch は query を受け取るだけで、入力文字列の state は持たない。持つのは呼び出し側だ。
// components/UserSearch.tsx
'use client'
export const UserSearch = () => {
const [query, setQuery] = useState('') // 入力欄の状態はここ
const { users, isLoading, error } = useUserSearch(query) // 結果はその派生
return (
<div>
<SearchInput value={query} onChange={setQuery} />
<SearchResults query={query} users={users} isLoading={isLoading} error={error} />
</div>
)
}query はキーストロークごとに更新される。一方、検索結果が変わるのは 300ms 後だ。更新頻度も寿命も違う 2 つを同じ Hook に同居させないほうが、それぞれの責務が素直になる。
| 分け方 | 得られるもの |
|---|---|
query を親の useState に置く | 入力欄の再描画と、検索結果の再描画が独立する |
Hook を「query → 結果」の派生にする | query を変えるだけでテストできる |
| 入力欄を controlled component にする | 供給元を差し替えられる(URL の ?q= と同期したくなったら useState を useSearchParams に置き換えるだけで Hook 側は無変更) |
逆に useUserSearch の中に入力欄の state と onChange まで持たせると、入力欄と検索ロジックが癒着してこの 3 つを全部失う。
結果の出し分けは、0 件表示を含めてこれだけになる。
// components/SearchResults.tsx
if (isLoading) return <Spinner />
if (error !== null) return <ErrorMessage message={error} />
if (query.trim() !== '' && users.length === 0) return <EmptyState />
return <UserList users={users} />「入力の状態」「検索の実行と中断」「結果の描画」が 3 つに分かれた。レースコンディション対策が閉じているのは真ん中だけで、外側の 2 つはそれを知らなくていい。
実務での選択肢: ライブラリに任せる
ここまで自前で書いたが、プロダクトコードなら TanStack Query を使うことが多い。abort・レースコンディション・キャッシュ・重複排除がライブラリ側の責務になる。
// hooks/useUserSearch.ts(TanStack Query 版)
import { keepPreviousData, useQuery } from '@tanstack/react-query'
export const useUserSearch = (keyword: string) => {
return useQuery({
queryKey: ['users', keyword],
enabled: keyword !== '',
placeholderData: keepPreviousData,
queryFn: async ({ signal }) => {
// signal はライブラリが渡してくる。キーが変われば自動で abort される
const response = await fetch(`/api/users?q=${encodeURIComponent(keyword)}`, { signal })
if (!response.ok) throw new Error(`検索に失敗した (${response.status})`)
return (await response.json()) as User[]
},
})
}デバウンスは別の Hook として合成する
デバウンスはライブラリの守備範囲外なので、これだけは自前で用意する。検索に限らない汎用 Hook として切り出せる。
// hooks/useDebouncedValue.ts
export const useDebouncedValue = <T,>(value: T, delayMs = 300): T => {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), delayMs)
return () => window.clearTimeout(timer) // value が変わるたびに前の予約を捨てる
}, [value, delayMs])
return debounced
}ここで役割が 1 段ずれる。
| 素の実装 | TanStack Query | |
|---|---|---|
| デバウンスが制御するもの | fetch をいつ呼ぶか(命令的) | queryKey をいつ変えるか(宣言的) |
| abort のトリガー | 自分で abort() を呼ぶ | キーが変わったらライブラリが呼ぶ |
デバウンス Hook は「fetch を遅らせる」のではなく「キーの変化を遅らせる」ものになる。キーが変わらなければクエリは動かないので、結果として API 呼び出しも遅れる、という順序だ。この間接性のおかげで、デバウンス Hook 側は fetch も abort も知らずに済む。
クリアはデバウンスしない
ただし、デバウンス済みの値をそのまま queryKey と enabled に渡すと UX が 1 点だけ劣化する。入力を全部消しても、300ms のあいだ前の検索結果が残ってしまう。「×ボタンを押したのに消えない」と感じる部分だ。
素の実装では、空文字ガードをデバウンスより前に置くことでこれを避けていた。
// 素の実装 — 入力を消した瞬間に畳む(デバウンスを経由しない)
if (keyword === '') {
controllerRef.current?.abort()
setState(initialState)
return
}同じ性質を持たせるには、空文字だけデバウンスをバイパスすればいい。
export const useUserSearch = (query: string) => {
const raw = query.trim()
const debounced = useDebouncedValue(raw, 300)
// クリアは待たせない。空文字だけデバウンスを飛ばす
const keyword = raw === '' ? '' : debounced
return useQuery({
queryKey: ['users', keyword],
enabled: keyword !== '',
placeholderData: keepPreviousData,
queryFn: async ({ signal }) => {
/* ... */
},
})
}教訓: デバウンスすべきなのは 増やす 操作だけ。 入力のように API 呼び出しを増やす方向は待たせる価値があるが、クリアやキャンセルのように減らす方向を待たせても得るものはなく、反応が鈍いという損だけが残る。
なお、入力の反応性そのものには影響しない。遅れるのは keyword だけで、query は呼び出し側の state なので入力欄はキーストロークに即時追従する。むしろキー方式のほうが待機中を検出しやすく、query.trim() !== keyword で「デバウンス待ちの 300ms」を取り出して、結果の opacity を落とすといった中間表現が作れる。素の実装ではこの間 isLoading すら立たないので、同じことをするには別のフラグが要る。
自前で潰したものが、それぞれこう対応する。
| 自前で書いたもの | TanStack Query |
|---|---|
controllerRef と abort() | queryFn の signal を fetch に渡すだけ |
stale チェック(commit) | queryKey 単位で結果が管理され、古い応答は捨てられる |
isLoading / error の手動管理 | 戻り値の isLoading / error(enabled: false のとき isPending は true のままなので isLoading を見る) |
| デバウンス | 対象外。useDebouncedValue を自作して queryKey に渡す |
| 同じ語での再検索 | キャッシュヒットで fetch 自体が起きない |
ただし、ライブラリは問題を消すのではなく、解いた結果を共有しているだけだ。queryKey が変わったとき古いリクエストがどう扱われるか、signal を fetch に渡し忘れると何が壊れるかは、ここまでの内容を踏まえないと読めない。
この「デバウンスで確定回数を減らす」と「stale な応答を無視する」がセットで要る構図は、検索に限らない。トグル 1 個の切替でも同じ形で出てくる話を 表示モード切替のフリーズは、トグルではなく同じコミットの重い更新だった に書いた。
まとめ
- レースコンディションは応答の到着順が保証されないことから来る。デバウンスは発火回数を減らすだけで、この問題は解かない
finallyは成功・失敗・中断のすべてで必ず実行される。catchでAbortErrorを弾いてもfinallyは素通しなので、中断されうる非同期処理ではfinallyに state 更新を置かない- 判定すべきは「ref の現在値がどれか」ではなく「自分自身が最新か」。主語を自分に置くと条件が 1 つに収束し、
signal.abortedという素直な形になる - stale チェックが要るのは「abort が例外として届かない場所」だけ。
signalを渡したfetchとそのボディ読み取りは abort で reject するので、tryの中の逐一チェックはほぼ通らない。効くのはcatch・finally・そしてsignalを渡していない非同期処理をまたいだ直後 abort()が効かないときは、abort()を呼ぶ側とsignalを渡した側が同じインスタンスかをまず疑う。レンダー本体で作った値は生存期間がレンダー 1 回分しかない