Notes

検索 Hook のレースコンディション — AbortController が効かない 3 つのパターンと、ガードが要る場所

インクリメンタルサーチのカスタム Hook で、古いリクエストの応答が新しい結果を上書きする問題を AbortController で潰す。ただし signal を渡しただけでは効かない。abort の呼び忘れ、finally の暴発、controller の再生成、エラー分岐でのガード漏れという典型的な壊れ方を順に見ていく。

#React#TypeScript#React Hooks#AbortController#非同期処理

検索ボックスに文字を打つたびに API を叩く UI を、React + TypeScript のカスタム Hook (useUserSearch) で書く。要件はこの 5 つ。

厄介なのは 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 しかしていない。

何が起きるか

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 は素通しになっている。

何が起きるか

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で、両者が一致しない。

何が起きるか

構図は 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 つ。

したがって、こう書きたくなるガードは実際にはほぼ通らない。

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 されても到達する

何が起きるか

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 個の切替でも同じ形で出てくる話を 表示モード切替のフリーズは、トグルではなく同じコミットの重い更新だった に書いた。

まとめ