こんにちは。フロントエンドの現場で数々の泥臭いパフォーマンスチューニングとバグの温床を鎮火してきたチーフアーキテクトだ。
今日もコードレビューをしていて、「とりあえず `useEffect` の依存配列に `[foo]` を入れておけば警告は消えるけど、なんで動いてるのかよく分かってません」という爆弾を見つけた。いや、気持ちは痛いほど分かる。Reactのレンダリングサイクルと同期機構は、一歩間違えると無限ループやメモリリークという名の特級呪物と化すからだ。
今回は、そんな `useEffect` の基本構文と実行タイミングについて、ブラウザの描画パイプラインやReactの内部スケジューリングの裏側まで踏み込んで、徹底的に解剖していこう。
—
1. `useEffect` は「ライフサイクルメソッド」ではないというパラダイムシフト
まず最初に、長年フロントエンドをやっているベテランほど陥りがちな罠を破壊しておこう。
`useEffect` は、`componentDidMount` や `componentDidUpdate` の「代替え」ではない。ここを勘違いしていると、いつまで経ってもReactの心臓部と分かり合えない。
Reactの本質は 「UIの宣言的射影 (Declarative Projection)」 だ。
`UI = f(state)` という数式において、`useEffect` はレンダリング結果(JSX)がDOMに反映された「後」に、外部世界(API、DOM、タイマー、WebSocketなど)へ副作用を「同期(Synchronize)」させるためのメカニズムに過ぎない。
実行タイミングの全体像:ブラウザは私たちを待ってくれない
コンポーネントがレンダリングされるとき、内部の処理は以下のタイムラインで流れていく。
1. Render Phase(レンダリングフェーズ)
- コンポーネント関数が実行され、新しいVirtual DOM(React Elementのツリー)が生成される。
- この段階では、まだ実際のDOMは1ミリも書き換わっていない。純粋なJavaScriptの計算世界だ。
2. Commit Phase(コミットフェーズ)
- ReactがDOMを実際のブラウザツリーに反映(Mutation)する。
3. Paint(ブラウザの描画)
- ブラウザが画面のレイアウトを計算し、ピクセルを画面に描画する。
- 重要: `useEffect` の副作用関数は、このブラウザの描画が完了した「後」に、非同期でスケジュールされる。
つまり、`useEffect` 内のコードは、ユーザーが画面を目視できるようになった後でこっそり実行される。これにより、重いデータフェッチやロギング処理がメインスレッドをブロックし、画面の初期描画(First Contentful Paint)を遅延させるのを防いでいるのだ。
—
2. 依存配列(Dependency Array)の数学的正体
`useEffect(effect, deps)` の第二引数である依存配列は、単なる「おまけ」ではない。これはReactに対して「この値が変わっていないなら、前回の副作用をスキップしてくれ」と伝えるための、メモ化の差分検知フィルターである。
ここで、依存配列の3つのモードによる挙動の違いを、内部挙動の観点から整理しておこう。
| 依存配列の指定 | 実行タイミングの挙動 | アーキテクチャ上のリスク・用途 |
| :— | :— | :— |
| 指定なし (`undefined`) | 毎回のレンダリング後に実行 | 毎フレームDOMを書き換えるような狂ったコード以外で使うべきではない。無限ループの温床。 |
| 空配列 (`[]`) | マウント時(初回のみ)に1回実行 | ライフサイクルの `Mount` に似ているが、クロージャの古い変数をキャプチャしやすいため注意が必要。 |
| 値あり (`[a, b]`) | マウント時 + `a` または `b` が前回のレンダリング時から変化した場合に実行 | 依存値の比較は `Object.is` による浅い比較(Shallow Comparison)で行われる。 |
`Object.is` の罠とメモリ効率の悪化
依存配列の比較は、内部的には `Object.is`(厳密等価演算子 `===` とほぼ同じ、ただし `NaN` の比較など例外あり)で行われる。ここで上級者でもハマるのが、「毎回新しく生成されるオブジェクトや関数」を依存配列に入れてしまうミスだ。
function ExpensiveComponent({ query }) {
// 毎回新しいオブジェクトリテラルが生成されるため、
// query が同じでも options は別の参照(Reference)を持つ!
const options = { limit: 10, filter: query };
useEffect(() => {
fetchData(options);
}, [options]); // ⚠️ 毎回のレンダリングで options の参照が変わるため、無限ループまたは不要なフェッチが発生する!
}
この問題を回避するには、オブジェクトや関数自体をプリミティブな値に分解するか、`useMemo` / `useCallback` で参照の同一性を担保(Referential Transparency)する必要がある。
—
3. 実践:競合(Race Condition)とクリーンアップの美学
非同期処理を `useEffect` の中で扱う際、最も恐ろしいのが「レスポンスの競合(Race Condition)」だ。
例えば、ユーザーが素早くタブを切り替えて `userId = 1` から `userId = 2` に変更したとする。ネットワークの遅延により、後からリクエストした `userId = 2` の結果が先に返ってきて、その直後に遅れていた `userId = 1` の古いレスポンスが返ってきて画面を上書きしてしまったら……? 致命的なバグの完成だ。
これを防ぐためには、クリーンアップ関数(副作用関数が返す関数)を活用し、「古いリクエストの結果を無効化(あるいはキャンセル)」する仕組みが不可欠となる。
以下に、実務でそのまま使える堅牢なカスタムフックの断片を見てみよう。
import React, { useState, useEffect } from ‘react’;
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 1. クリーンアップ時に無効化するためのフラグを用意
let isCancelled = false;
const fetchUserData = async () => {
setLoading(true);
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
// 2. このエフェクトが破棄(クリーンアップ)されていなければ状態を更新
if (!isCancelled) {
setUser(data);
setLoading(false);
}
} catch (error) {
if (!isCancelled) {
console.error(‘Failed to fetch user:’, error);
setLoading(false);
}
}
};
fetchUserData();
// 3. クリーンアップ関数の返却
// 次回のエフェクト実行前、またはコンポーネントのアンマウント時に必ず走る
return () => {
isCancelled = true;
};
}, [userId]); // userId が変わるたびに、前回のクリーンアップが走ってフラグが立つ
if (loading) return
;
if (!user) return
;
return
;
}
このパターンがなぜ強力か?
Reactは、コンポーネントが再レンダリングされて新しい副作用を実行する「前」に、前回のレンダリングで返されたクリーンアップ関数を必ず実行する仕様になっているからだ。これにより、メモリリークの防止だけでなく、非同期処理の非同期的な状態汚染を完璧にブロックできる。
—
4. チーフアーキテクトからの提言:本当に `useEffect` は必要か?
最後に、少し踏み込んだ話をしよう。
優れたReactアーキテクトは、「`useEffect` を書かない方法を最初に考える」。
多くのジュニア・ミドルクラスのエンジニアは、状態が変化したときに別の状態を更新したい(派生状態を作りたい)場合、すぐに `useEffect` を使いたがる。
// ❌ アンチパターン:useEffect で派生状態を同期している
const [firstName, setFirstName] = useState(‘John’);
const [lastName, setLastName] = useState(‘Doe’);
const [fullName, setFullName] = useState(‘John Doe’);
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
これをやってしまうと、以下の問題が発生する。
1. 初回レンダリング
2. `firstName` の更新による再レンダリング
3. `useEffect` の発火 + `setFullName` による「無駄な2回目の再レンダリング」
これはパフォーマンス上の悪夢だ。答えはシンプルで、計算可能な値はレンダリング中にその場で算出(Derive)すればいい。
// ⭕️ 正しいアプローチ:レンダリング中に計算する(余計な state も useEffect も不要)
const [firstName, setFirstName] = useState(‘John’);
const [lastName, setLastName] = useState(‘Doe’);
const fullName = `${firstName} ${lastName}`; // 常に同期され、余計なレンダリングも発生しない
`useEffect` は、あくまで「Reactの管理外の外部システム(DOM、Network、Timersなど)とReactの世界を同期するための逃げ道(Escape Hatch)」である。この原則を胸に刻むだけで、あなたの書くReactコードの堅牢性とパフォーマンスは劇的に跳ね上がるはずだ。
さあ、エディタを開いて、無駄な `useEffect` を削ぎ落としに行こうか。

コメント