中級9分で読める

【解決】useEffectでasync/awaitを使う正しい方法と注意点

useEffectに直接asyncを付けてはいけない理由と、正しいasync/awaitの書き方を解説。内部関数として定義する方法・クリーンアップとの組み合わせ・競合状態(race condition)の防ぎ方まで実例付きで説明します。

async/await に慣れると、useEffectにも当然のようにつけたくなる。

// ❌ これはNG
useEffect(async () => {
  const data = await fetchUser()
  setUser(data)
}, [])

動いているように見えることもある。でもこれはReactの仕様に反していて、気づきにくいバグの原因になる。なぜいけないのか、どう書けばいいのかを整理する。

なぜuseEffectにasyncを直接付けてはいけないのか

useEffectのコールバックには「契約」がある。クリーンアップ関数、またはundefined(何も返さない) を返さなければならない。

// useEffectの正しい使い方
useEffect(() => {
  // 何かの処理

  return () => {
    // クリーンアップ(任意)
  }
}, [])

async 関数は、どんなときでも必ず Promise を返す。

async function example() {
  return 'hello'
}
example()  // → Promise<string>  ※ 'hello' ではなくPromiseが返る

だからuseEffectのコールバックに async を付けると、ReactはクリーンアップするつもりでPromiseを受け取ることになる。クリーンアップ関数として扱えないPromiseが渡されることで、Reactが警告を出し、メモリリークやアンマウント後の状態更新といった問題が起きやすくなる。

正しい書き方:内部で非同期関数を作って呼ぶ

解決策はシンプルだ。非同期処理をuseEffectの「内側」に関数として定義して、それを呼べばいい。

useEffect(() => {
  const fetchData = async () => {
    const data = await fetchUser()
    setUser(data)
  }

  fetchData()  // 定義してから呼ぶ
}, [])

このやり方なら、useEffectのコールバック自体はasyncではない(Promiseを返していない)ので契約を守れる。

即時実行関数(IIFE)という書き方もある。

useEffect(() => {
  ;(async () => {
    const data = await fetchUser()
    setUser(data)
  })()
}, [])

どちらでも動くが、前者のほうが読みやすく、デバッグのときに関数名が表示されるので使いやすい。

エラーハンドリングを加える

実際のコードではエラーと読み込み中の状態も扱う。

const [user, setUser] = useState<User | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)

useEffect(() => {
  const fetchData = async () => {
    try {
      const data = await fetchUser()
      setUser(data)
    } catch (err) {
      setError('データの取得に失敗しました')
    } finally {
      setLoading(false)
    }
  }

  fetchData()
}, [])

finally を使うと成功・失敗どちらの場合も setLoading(false) が呼ばれる。

コンポーネントがアンマウントされた後の問題

タブを切り替えるなどしてコンポーネントが消えた(アンマウントされた)後も、リクエストは続いている。リクエストが完了してstateを更新しようとすると、Reactが警告を出す。

フラグで「まだ有効かどうか」を管理して防ぐ。

useEffect(() => {
  let cancelled = false  // このエフェクトが有効かどうかのフラグ

  const fetchData = async () => {
    try {
      const data = await fetchUser()
      if (!cancelled) {   // アンマウント後はstateを更新しない
        setUser(data)
      }
    } catch (err) {
      if (!cancelled) {
        setError('エラーが発生しました')
      }
    }
  }

  fetchData()

  return () => {
    cancelled = true  // クリーンアップでフラグを立てる
  }
}, [])

競合状態(Race Condition)を防ぐ

依存配列に userId があって、userId が素早く変わる場合を考える。

userId=1 のリクエスト開始 → userId=2 のリクエスト開始 → userId=2 のレスポンス到着 → userId=1 のレスポンス到着(遅延)

こうなると最終的に userId=1 のデータが画面に表示されてしまう。userId=2 が正しいのに。

同じフラグの仕組みが有効だ。新しいuseEffectが実行されるとき、前のクリーンアップ関数が呼ばれて古い cancelledtrue になる。

useEffect(() => {
  let cancelled = false

  const fetchData = async () => {
    const data = await fetchUserById(userId)
    if (!cancelled) {
      setUser(data)  // userId=1 のレスポンスが遅れて届いても、cancelled=trueで無視される
    }
  }

  fetchData()

  return () => {
    cancelled = true  // 次のuseEffectが走る前にキャンセルされる
  }
}, [userId])

fetchAPIをキャンセルする方法(AbortController)

フラグを使う方法は「レスポンスが来ても使わない」という仕組みだが、fetch API には「リクエスト自体を中断する」機能がある。

useEffect(() => {
  const controller = new AbortController()

  const fetchData = async () => {
    try {
      const res = await fetch('/api/user', { signal: controller.signal })
      const data = await res.json()
      setUser(data)
    } catch (err) {
      if (err instanceof Error && err.name === 'AbortError') return  // キャンセル時は無視
      setError('エラーが発生しました')
    }
  }

  fetchData()

  return () => {
    controller.abort()  // クリーンアップ時にリクエストを中断
  }
}, [])

ネットワークレベルでリクエストを止めるので、フラグより確実だ。ただし axios など一部のライブラリではAPIが違う。

複雑になったらライブラリを検討する

データ取得のロジックが複雑になってきたら、専用ライブラリを使うのも現実的な選択だ。

  • TanStack Query(React Query):キャッシュ・ローディング・エラー・再取得をまとめて管理できる
  • SWR:シンプルなキャッシュ付きのデータフェッチ

ライブラリを使えば、キャンセル・競合状態の処理を自前で書かずに済む。大きなアプリケーションではかなりコードが減る。

このエラーと向き合うための1つの考え方

useEffectのコールバックはasyncにしない。内側に作った関数の中にasyncを閉じ込める。

「asyncをどこに付けるか」という問題ではなく、「useEffectのコールバックはPromiseを返せない」という仕様を踏まえた書き方だ。この制約を覚えておけば、迷わなくなる。