やあ、調子はどうだい?
最近、コードレビューをしていて一番「あー、また踏み抜いてるな」って思うポイントがどこだか分かるかい?そう、今回のテーマである `useEffect` 内での非同期処理の扱い だ。
中級に差し掛かったエンジニアが、なぜか「とりあえず `useEffect` のコールバックに `async` つけとけばいっか」という魔界の呪文を唱えて自爆するシーンを、俺はもう数え切れないほど見てきた。
今日は、なぜそれがタブーなのかというReactの心臓部の話から、ブラウザが裏側でどう動いているか、そして現場で私たちがどうやってこの非同期の荒波をスマートに乗りこなすべきか、シニアの視点からみっちり伝授しよう。コーヒーでも飲みながら、じっくり聞いてくれ。
—
なぜ `useEffect` の第一引数を `async` にしてはいけないのか?
まず最初に、このコードを見てほしい。君のチームの誰かが、いや、もしかしたら過去の君自身が書いたかもしれないコードだ。
// 【アンチパターン】絶対にやってはいけない書き方
useEffect(async () => {
const data = await fetchUserData(userId);
setUser(data);
}, [userId]);
一見すると、何がダメなのか分からないかもしれない。「データ取れてるじゃん」って思うよな?
だが、これこそがReactのライフサイクルを盛大にバグらせる原因なんだ。
Reactが期待している「戻り値」の正体
JavaScriptの `async` 関数は、何を返すか知っているかい? そう、無条件に「Promiseオブジェクト」を返すんだ。
一方で、Reactの `useEffect` が第一引数の関数に求めている戻り値は一体何だったか?
それは、コンポーネントがアンマウントされる時や、副作用が再実行される直前にクリーンアップを行うための「クリーンアップ関数(関数)」だ。
つまり、`useEffect(async () => {})` と書いた瞬間、Reactは「おっ、クリーンアップ関数としてPromiseが返ってきたぞ?よし、コンポーネントが消える時にこのPromiseを関数として呼び出すか!」と盛大な勘違いをする。
結果として、アンマウント時に `TypeError: 〇〇 is not a function` というエラーがコンソールに爆誕するわけだ。ブラウザは悪くない。騙したこっちが悪い。
—
ブラウザの裏側とReactの同期メカニズム
じゃあ、もう少し解像度を上げて、ブラウザが裏側でどう動いているのかを覗いてみよう。
Reactのレンダリングパイプラインは、大まかに以下のステップで進む。
1. Render Phase(レンダリングフェーズ): コンポーネントが実行され、新しい仮想DOMツリーが生成される(ここは純粋関数であるべきで、副作用は禁止)。
2. Commit Phase(コミットフェーズ): Reactが実際のDOMを更新する。
3. Paint(ブラウザの描画): ブラウザが画面をピクセルとして描き出す。
4. Layout Effects (`useLayoutEffect`) の実行: ペイント前に同期的に実行される。
5. Effects (`useEffect`) の非同期実行: ブラウザの描画が完了した後、メインスレッドが空いたタイミングで非同期的に実行される。
ここで重要なのは、`useEffect` 自体は「描画の邪魔をしないように後回しで実行される」ということだ。
しかし、その中でさらに `async/await` を使って非同期処理を待ち受けようとすると、Reactのライフサイクル管理と、JavaScriptの非同期タスクキュー(Microtask Queue)のタイミングが複雑に絡み合う。
副作用関数自体を `async` にしてしまうと、Reactは副作用の完了タイミングを完全にコントロールできなくなる。これが、予期せぬ競合状態(Race Condition)や、メモリリークの温床になるんだ。
—
現場の正解:関数内部で `async` 関数を定義して即時実行する
じゃあ、どう書くのが正解なのか。
答えはシンプルだ。`useEffect` のコールバック自体は同期関数のままにしておき、その内部で `async` な関数を定義して、即座に呼び出す(IIFEや通常の関数定義)。
百聞は一見にしかず。実務でそのまま使える、最高にクリーンなサンプルコードを見せよう。
import React, { useState, useEffect } from ‘react’;
export const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// 1. 競合状態(Race Condition)を防ぐためのフラグ
let isMounted = true;
// 2. useEffectの内部でasync関数を定義する
const fetchUserData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error(‘データの取得に失敗しました’);
}
const data = await response.json();
// 3. 非同期処理が完了した時点で、コンポーネントがまだマウントされていれば状態を更新
if (isMounted) {
setUser(data);
}
} catch (err) {
if (isMounted) {
setError(err.message);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
// 4. 定義した関数を即座に実行する
fetchUserData();
// 5. クリーンアップ関数でフラグを倒し、アンマウント後の状態更新を防ぐ
return () => {
isMounted = false;
};
}, [userId]); // userIdが変わるたびに安全に再フェッチされる
if (loading) return
;
if (error) return
;
if (!user) return null;
return (
{user.name}
{user.email}
);
};
—
シニアが教える、現場の泥臭い知見とTips
上記のコードだけでも十分プロダクションレベルだが、実務の現場ではさらに一歩踏み込んだ配慮が必要になる。ここからは、現場で生き残るための実践知をいくつか授けよう。
1. 競合状態(Race Condition)への対策
ユーザーが素早くページを行き来したり、`userId` が `1` から `2` に瞬時に切り替わった時を想像してほしい。
ネットワークの遅延によっては、先にリクエストした `userId=1` のレスポンスが、後からリクエストした `userId=2` のレスポンスよりも遅く返ってくることがある。
もし上記の `isMounted` フラグや、後述の `AbortController` を使っていないと、古いデータ(userId=1)で新しい画面が上書きされてしまう。これはバグの温床だ。
2. `AbortController` による通信のキャンセル
もっとモダンでスマートなやり方として、Fetch APIの `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) {
// キャンセルされたエラーは無視し、それ以外をハンドリング
if (error.name !== ‘AbortError’) {
setError(error);
}
}
};
fetchData();
// クリーンアップでリクエストをアボートする
return () => {
controller.abort();
};
}, [dependency]);
これ、めちゃくちゃクールだろ? 無駄な通信を走らせないから、サーバーにも優しい。
—
まとめ:これだけは覚えておけ
1. `useEffect` の第一引数に `async` は絶対につけるな。 戻り値のPromiseがクリーンアップ関数と誤認されてバグる。
2. 非同期処理を書きたいなら、副作用関数の「中」で `async` 関数を定義して即時実行しろ。
3. アンマウント後や再フェッチ時の状態更新(競合状態)を防ぐために、フラグ管理か `AbortController` を使え。
Reactでの非同期処理の扱いは、最初のうちは回りくどく感じるかもしれない。だけど、この作法をマスターすれば、君の書くコードの堅牢性は一段も二段も跳ね上がる。
さて、理論はここまでだ。早速エディタを開いて、自分のプロジェクトにある怪しい `useEffect(async …)` を駆逐してこい!何か詰まったらいつでも声をかけてくれ。頼もしい仲間からの質問はいつでも大歓迎だ。

コメント