useEffectの亡霊たちへ:なぜ「async/await」をその直前に書いてはいけないのか
こんにちは。日々、巨大なReactアプリケーションのコードベースを監査し、メモリリークやステートの競合と格闘しているチーフアーキテクトだ。
今日もコードレビューをしていて、こんなコードを見かけた。
// 🚨 やりがちアンチパターン:絶対にしてはいけない書き方
useEffect(async () => {
const data = await fetchUserData(userId);
setUser(data);
}, [userId]);
美しく、シンプルに見えるだろう? だが、これこそがReactのライフサイクルと非同期処理の非対称性を無視した、静かなる時限爆弾なのだ。
今回は、なぜ `useEffect` のコールバックを `async` にしてはいけないのか、そしてブラウザのエンジンやReactのレンダリングパイプラインの裏側で何が起きているのかを、骨の髄まで解説しよう。上級エンジニアである君なら、この仕様の「理由」を深く理解し、二度とこの罠に引っかからないはずだ。
—
なぜ `useEffect` のコールバック自体を `async` にできないのか?
結論から言おう。`useEffect` が期待している戻り値は「何も返すか」、あるいは「クリーンアップ関数(関数)」のどちらかだからだ。
JavaScriptの `async` 関数は、問答無用で `Promise` オブジェクトを返す。
もし `useEffect` に `async` を渡してしまうと、Reactは副作用関数の戻り値として `Promise` を受け取ることになる。
Reactのクリーンアップ機構(コンポーネントのアンマウント時や依存配列変更時の処理)は、返ってきた値が「関数」であることを期待している。そこに `Promise` が返ってきたらどうなるか?
// React内部のクリーンアップ処理の擬似コード(イメージ)
const cleanup = effectCallback();
// async関数を渡すと cleanup は Promise インスタンスになる!
if (typeof cleanup === ‘function’) {
cleanup(); // ここで TypeError にはならないが、Promiseなので何も実行されず、クリーンアップが完全に silent fail する
}
クリーンアップが機能しないということは、非同期処理の完了前にコンポーネントがアンマウントされた場合、アンマウントされたコンポーネントの state を更新しようとしてメモリリークや致命的な警告(Can’t perform a React state update on an unmounted component)を引き起こす。これが、Reactがこの仕様を頑なに守っている理由だ。
—
正解のアーキテクチャ:内部即時実行関数(IIFE)とキャンセル機構
では、`useEffect` 内で安全に非同期処理を扱うにはどうすればいいのか?
答えはシンプルだ。`useEffect` 自体は同期的に保ち、その内部で `async` 関数を定義して即時実行(IIFE)する。さらに、レースコンディション(競合)とメモリリークを防ぐための「防衛的コード」をセットで実装する。
実際のプロダクションコードに耐えうる、最も堅牢なパターンを見てみよう。
import { useState, useEffect } from ‘react’;
export function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// 1. このエフェクトのスコープ内でのみ有効なキャンセル用フラグ
let isCancelled = false;
// 2. useEffectの内部でasync関数を定義する
const fetchUser = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(‘データの取得に失敗しました’);
}
const data = await response.json();
// 3. 非同期処理が完了した時点で、コンポーネントがまだ生存しているか確認
if (!isCancelled) {
setUser(data);
}
} catch (err) {
if (!isCancelled) {
setError(err.message);
}
} finally {
if (!isCancelled) {
setLoading(false);
}
}
};
fetchUser();
// 4. クリーンアップ関数でフラグを倒し、古い非同期処理の結果を「無効化」する
return () => {
isCancelled = true;
};
}, [userId]); // userIdが変わるたびに前回の処理はキャンセルされる
if (loading) return
;
if (error) return
;
if (!user) return null;
return
;
}
—
高度なエンジニアが考慮すべき「3つの罠」
この実装スタイルを採用するにあたり、シニアエンジニアが頭に入れておくべきポイントを整理しておく。
1. レースコンディション(競合状態)の防止
ユーザーが素早く `userId` を `1` から `2` に変更したとしよう。
ネットワークの遅延により、後からリクエストした `userId: 2` の結果が先に返ってきて、その直後に遅れていた `userId: 1` のレスポンスが返ってきた場合、画面には「古いデータ(userId: 1)」が表示されてしまう。
上記のコードで使っている `isCancelled` フラグ(あるいは `AbortController`)は、「最後に走ったエフェクト以外の古い結果をすべてゴミとして捨てる」ための極めて重要なアーキテクチャパターンだ。
2. `AbortController` によるネットワーク層での無駄なリクエスト破棄
先ほどの `isCancelled` は JavaScript のメモリ上の状態更新を防ぐものだが、ブラウザのネットワーク帯域やサーバのリソースを無駄に消費し続ける点は解決していない。
本格的なアプリケーションでは、`AbortController` を組み合わせるのがベストプラクティスだ。
useEffect(() => {
const controller = new AbortController();
const { signal } = controller;
const fetchData = async () => {
try {
const res = await fetch(`/api/data`, { signal });
const json = await res.json();
setData(json);
} catch (error) {
// AbortErrorの場合は意図的なキャンセルなのでエラーとして扱わない
if (error.name !== ‘AbortError’) {
setError(error);
}
}
};
fetchData();
// アンマウント時や依存配列変更時にHTTPリクエスト自体を中断する
return () => controller.abort();
}, [dependency]);
ブラウザのネイティブな `fetch` や `Axios` が持つキャンセル機構とReactのライフサイクルを同期させることで、パフォーマンスとメモリ効率は限界まで最適化される。
3. 依存配列(Dependencies Array)の罠
非同期関数内で使っている変数や関数は、原則としてすべて依存配列に含める必要がある。もし関数をコンポーネント内で定義して `useEffect` の外(あるいは中)で呼び出す場合、`useCallback` でメモ化し忘れると、毎レンダリングごとにエフェクトが発火し、無限ループの沼にハマるか、不要な再フェッチの嵐に見舞われることになる。
—
まとめ:Reactの哲学に逆らうな
Reactは、UIを「状態(State)の純粋な関数」として捉えることから始まった。
`useEffect` は、その宣言的な世界と、副作用という「命令型(Imperative)な現実世界」を繋ぐための唯一の安全弁だ。
非同期処理を扱う際、私たちはどうしても「上から下へ流れる手続き型」の脳みそでコードを書きがちになる。だが、コンポーネントはいつ破棄されるか分からないし、データはいつ前後して返ってくるか分からない。
「副作用関数自体を `async` にしない」
「内部で `async` を定義し、クリーンアップで状態をガードする」
この規律をチーム全体で徹底するだけで、プロダクション環境で発生する不可解なバグやメモリリークの何割かを未然に防ぐことができるはずだ。
さあ、今すぐ君のコードベースを開き、`useEffect(async () => { … })` の亡霊が巣食っていないか確認してみたまえ。

コメント