【解決】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が実行されるとき、前のクリーンアップ関数が呼ばれて古い cancelled が true になる。
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を返せない」という仕様を踏まえた書き方だ。この制約を覚えておけば、迷わなくなる。