React を使っていると、「APIからデータを取得したい」「外部ライブラリを初期化したい」「イベントリスナーを登録したい」という場面に出くわします。これらはすべて「副作用(Side Effect)」と呼ばれる操作で、useEffect フックがその解決策です。
イベントリスナーとは
ブラウザのウィンドウサイズ変更やキーボード入力など、ユーザーやシステムからのイベントに応じて処理を実行する仕組みです。addEventListener を使って登録し、removeEventListener で解除します。
1. なぜ useEffect が必要か
1.1 React コンポーネントの「純粋性」
React コンポーネントは純粋関数であるべきです。同じ props と state を与えれば、常に同じ JSX(TSX) を返す必要があります。
💡 純粋関数とは
純粋関数とは、同じ入力に対して常に同じ出力を返し、副作用を持たない関数のことです。例えば、数学の関数 f(x) = x + 1 は純粋関数です。一方、副作用を持つ関数は、外部の状態を変更したり、外部からの影響を受けたりします。
💡 propsやstateとは
props(プロパティ)とは、コンポーネントに渡される外部からのデータです。state(状態)とは、コンポーネント内部で管理されるデータで、ユーザーの操作やその他の要因によって変化します。これらはコンポーネントのレンダリングに影響を与えます。
// ✅ 純粋なコンポーネント
function Greeting({ name }) {
return <h1>Hello, {name}!</h1>;
}
しかし、実際のアプリケーションでは純粋な計算だけでは済みません:
- ネットワークリクエスト: APIからデータを取得
- DOM 操作: 要素のサイズ測定、フォーカス管理
- サブスクリプション: WebSocket、イベントリスナー
- タイマー: setInterval、setTimeout
これらは外部システムとの同期であり、React のレンダリングサイクルの外側で行う必要があります。
1.2 useEffect の役割
useEffect は、レンダリング後に副作用を実行するためのフックです。
💡 副作用とは
副作用とは、コンポーネントのレンダリング以外の処理を指します。例えば、データの取得、DOM の操作、サブスクリプションの登録などが含まれます。
具体的には、以下のような操作が副作用に該当します:
- ネットワークリクエスト(API からのデータ取得)
- DOM の直接操作(要素のサイズ測定、スクロール位置の設定)
- イベントリスナーの登録と解除
- タイマーの設定(setTimeout、setInterval)
💡 レンダリング後に実行する理由
副作用は DOM が更新された後に実行する必要があります。例えば、要素のサイズを測定する場合、DOM が最新の状態でなければ正しい値が得られません。
import { useState, useEffect } from 'react';
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
// セットアップ: 接続を開始
const connection = createConnection(roomId);
connection.connect();
// クリーンアップ: 接続を解除
return () => {
connection.disconnect();
};
}, [roomId]); // 依存配列: roomId が変わったら再実行
return <MessageList messages={messages} />;
}
1.3 useEffect の API
useEffect(setup, dependencies?)
| 引数 | 説明 |
|---|---|
setup |
副作用のロジックを含む関数。クリーンアップ関数を返すことができる |
dependencies |
省略可能。依存配列。この値が変わった時にエフェクトが再実行される |
💡 クリーンアップ関数とは
setup 関数は、必要に応じてクリーンアップ関数を返すことができます。クリーンアップ関数は、エフェクトが再実行される前やコンポーネントがアンマウントされる際に呼び出され、リソースの解放やサブスクリプションの解除などを行います。
💡 依存配列とは
依存配列は、エフェクトが再実行される条件を指定します。配列内の値が前回のレンダー時と異なる場合にのみ、エフェクトが再実行されます。依存配列を省略すると、エフェクトは毎回のレンダー後に実行されます。
依存配列のパターン
// パターン1: 依存配列あり → 依存値が変わった時のみ再実行
useEffect(() => {
// roomId か serverUrl が変わった時に実行
}, [roomId, serverUrl]);
// パターン2: 空の依存配列 → マウント時のみ実行
useEffect(() => {
// コンポーネントのマウント時に1回だけ実行
}, []);
// パターン3: 依存配列なし → 毎回のレンダー後に実行
useEffect(() => {
// 毎回のレンダー後に実行(通常は避けるべき)
});
1.4 エフェクトのライフサイクル
💡 Strict Mode での二重実行
開発環境では、React は意図的にセットアップ → クリーンアップ → セットアップを実行します。これはクリーンアップロジックが正しく実装されているかを確認するためのストレステストです。
2. useEffect の内部構造を徹底解剖
useEffect を使うたびに、React の内部では複数のモジュールが連携して動いています。この章では、facebook/react リポジトリの実際のコードを追いながら、その動作原理を解説します。
2.0 全体像: useEffect が動く仕組み
🎣 useEffect(フック呼び出し)
↓
📝 Effect オブジェクトを作成
↓
📋 updateQueue に追加
↓
🎨 レンダーフェーズ完了
↓
🖼️ ブラウザ描画
↓
⚡ Passive Effects 実行(非同期)
重要なポイント:useEffect は HookPassive フラグを使い、ブラウザ描画後に非同期で実行されます!
2.1 エントリポイント: packages/react/src/ReactHooks.js
// packages/react/src/ReactHooks.js
export function useEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
if (__DEV__) {
if (create == null) {
console.warn(
'React Hook useEffect requires an effect callback. Did you forget to pass a callback to the hook?',
);
}
}
const dispatcher = resolveDispatcher();
return dispatcher.useEffect(create, deps);
}
2.2 コア実装: mountEffect と updateEffect
初回レンダー時の処理
// packages/react-reconciler/src/ReactFiberHooks.js
function mountEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
if (
__DEV__ &&
(currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode
) {
// Strict Mode では追加のフラグを設定
mountEffectImpl(
MountPassiveDevEffect | PassiveEffect | PassiveStaticEffect,
HookPassive,
create,
deps,
);
} else {
mountEffectImpl(
PassiveEffect | PassiveStaticEffect,
HookPassive, // ← Passive Effect として登録
create,
deps,
);
}
}
function updateEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
updateEffectImpl(PassiveEffect, HookPassive, create, deps);
}
Effect フラグの種類
// packages/react-reconciler/src/ReactHookEffectTags.js
export const NoFlags = /* */ 0b0000;
export const HasEffect = /* */ 0b0001; // Effect を実行すべき
export const Insertion = /* */ 0b0010; // useInsertionEffect
export const Layout = /* */ 0b0100; // useLayoutEffect
export const Passive = /* */ 0b1000; // useEffect
| フック | フラグ | 実行タイミング |
|---|---|---|
useInsertionEffect |
Insertion |
DOM 変更前 |
useLayoutEffect |
Layout |
DOM 変更後、描画前(同期) |
useEffect |
Passive |
描画後(非同期) |
2.3 Effect オブジェクトの作成: mountEffectImpl
// packages/react-reconciler/src/ReactFiberHooks.js
function mountEffectImpl(
fiberFlags: Flags,
hookFlags: HookFlags,
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
// Hook ノードを作成
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
// Fiber にフラグを設定(後でコミットフェーズで参照)
currentlyRenderingFiber.flags |= fiberFlags;
// Effect オブジェクトを作成し、Hook に保存
hook.memoizedState = pushSimpleEffect(
HookHasEffect | hookFlags, // 初回は必ず実行
createEffectInstance(),
create,
nextDeps,
);
}
2.4 Effect オブジェクトの構造
// packages/react-reconciler/src/ReactFiberHooks.js
function pushSimpleEffect(
tag: HookFlags,
inst: EffectInstance,
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): Effect {
const effect: Effect = {
tag, // フラグ(Passive, Layout など)
create, // セットアップ関数
deps, // 依存配列
inst, // { destroy: クリーンアップ関数 }
next: (null: any), // 循環リストの次の Effect
};
return pushEffectImpl(effect);
}
function createEffectInstance(): EffectInstance {
return {destroy: undefined}; // クリーンアップ関数を保持
}
Effect の循環リスト構造
Fiber.updateQueue.lastEffect
↓
Effect1 → Effect2 → Effect3 → Effect1(循環)
↑ ↓
└──────────────────────────────┘
2.5 更新時の処理: updateEffectImpl
// packages/react-reconciler/src/ReactFiberHooks.js
function updateEffectImpl(
fiberFlags: Flags,
hookFlags: HookFlags,
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const effect: Effect = hook.memoizedState;
const inst = effect.inst;
if (currentHook !== null) {
if (nextDeps !== null) {
const prevEffect: Effect = currentHook.memoizedState;
const prevDeps = prevEffect.deps;
// 依存配列を比較
if (areHookInputsEqual(nextDeps, prevDeps)) {
// 変更なし → HookHasEffect フラグなしで登録(実行されない)
hook.memoizedState = pushSimpleEffect(
hookFlags, // HookHasEffect なし
inst,
create,
nextDeps,
);
return;
}
}
}
// 変更あり → HookHasEffect フラグ付きで登録(実行される)
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushSimpleEffect(
HookHasEffect | hookFlags,
inst,
create,
nextDeps,
);
}
💡 依存配列の比較
areHookInputsEqual は各依存値を Object.is で比較します。オブジェクトや配列は参照が変わると「変更あり」と判定されます。
2.6 実行タイミング: Passive Effects
useEffect は flushPassiveEffects で非同期に実行されます
// packages/react-reconciler/src/ReactFiberWorkLoop.js
function flushPassiveEffects(): boolean {
if (pendingEffectsStatus !== PENDING_PASSIVE_PHASE) {
return false;
}
const renderPriority = lanesToEventPriority(pendingEffectsLanes);
const priority = lowerEventPriority(DefaultEventPriority, renderPriority);
try {
setCurrentUpdatePriority(priority);
return flushPassiveEffectsImpl();
} finally {
setCurrentUpdatePriority(previousPriority);
}
}
2.7 Effect の実行: commitHookEffectListMount
// packages/react-reconciler/src/ReactFiberCommitEffects.js
export function commitHookEffectListMount(
flags: HookFlags,
finishedWork: Fiber,
) {
const updateQueue = finishedWork.updateQueue;
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
// フラグが一致する Effect のみ実行
if ((effect.tag & flags) === flags) {
// セットアップ関数を実行
const create = effect.create;
const inst = effect.inst;
const destroy = create(); // ← ここでセットアップ実行!
inst.destroy = destroy; // クリーンアップ関数を保存
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
2.8 クリーンアップの実行
クリーンアップは以下のタイミングで実行されます:
- 依存配列が変わった時: 新しいセットアップの前に古いクリーンアップを実行
- アンマウント時: 最後のクリーンアップを実行
// クリーンアップの実行(簡略化)
function commitHookEffectListUnmount(flags, finishedWork) {
const lastEffect = finishedWork.updateQueue?.lastEffect;
if (lastEffect !== null) {
let effect = lastEffect.next;
do {
if ((effect.tag & flags) === flags) {
const inst = effect.inst;
const destroy = inst.destroy;
if (destroy !== undefined) {
inst.destroy = undefined;
destroy(); // ← クリーンアップ実行!
}
}
effect = effect.next;
} while (effect !== lastEffect.next);
}
}
2.9 まとめ: useEffect の内部構造
処理フローの5ステージ
- mountEffect / updateEffect: Effect オブジェクトを作成
- pushSimpleEffect: 循環リストに追加
-
依存配列の比較:
areHookInputsEqualで変更を検出 - flushPassiveEffects: ブラウザ描画後に非同期で実行
- commitHookEffectListMount: セットアップ関数を実行、クリーンアップを保存
useLayoutEffect との比較
| 項目 | useEffect | useLayoutEffect |
|---|---|---|
| フラグ | HookPassive |
HookLayout |
| 実行タイミング | 描画後(非同期) | 描画前(同期) |
| ブロッキング | しない | する |
| 用途 | データ取得、サブスクリプション | DOM 測定、視覚的な調整 |
3. 代表的ユースケース
3.1 外部システムへの接続
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <div>Chat Room: {roomId}</div>;
}
3.2 イベントリスナーの登録
function WindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const handleResize = () => {
setSize({ width: window.innerWidth, height: window.innerHeight });
};
handleResize(); // 初期値を設定
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return <div>{size.width} x {size.height}</div>;
}
3.3 データフェッチ
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let ignore = false; // 競合状態を防ぐ
async function fetchUser() {
setLoading(true);
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
if (!ignore) {
setUser(data);
setLoading(false);
}
}
fetchUser();
return () => { ignore = true; };
}, [userId]);
if (loading) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
⚠️ データフェッチの注意点
エフェクト内でのデータフェッチは競合状態(race condition)を引き起こす可能性があります。ignore フラグを使うか、React Query や SWR などのライブラリの使用を検討してください。
3.4 タイマー
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // 更新関数を使用
}, 1000);
return () => clearInterval(id);
}, []); // 空の依存配列
return <div>Count: {count}</div>;
}
3.5 カスタムフックへの抽出
function useChatRoom({ roomId, serverUrl }) {
useEffect(() => {
const connection = createConnection({ serverUrl, roomId });
connection.connect();
return () => connection.disconnect();
}, [roomId, serverUrl]);
}
// 使用側
function ChatRoom({ roomId }) {
useChatRoom({ roomId, serverUrl: 'https://localhost:1234' });
return <div>Chat Room</div>;
}
4. パフォーマンスと注意点
4.1 不要な依存値を削除する
// ❌ オブジェクトがレンダーごとに新しく作られる
function ChatRoom({ roomId }) {
const options = { serverUrl: 'https://localhost:1234', roomId };
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]); // 毎回新しいオブジェクト → 毎回再実行
}
// ✅ エフェクト内でオブジェクトを作成
function ChatRoom({ roomId }) {
useEffect(() => {
const options = { serverUrl: 'https://localhost:1234', roomId };
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // プリミティブ値のみ依存
}
4.2 更新関数を使って state 依存を削除
// ❌ count を依存配列に含める必要がある
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]); // count が変わるたびにインターバルを再設定
// ✅ 更新関数を使う
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // 更新関数
}, 1000);
return () => clearInterval(id);
}, []); // 依存配列が空に
4.3 useEffect が不要なケース
// ❌ props/state から計算できる値にエフェクトは不要
function TodoList({ todos, filter }) {
const [filteredTodos, setFilteredTodos] = useState([]);
useEffect(() => {
setFilteredTodos(todos.filter(t => t.status === filter));
}, [todos, filter]);
return <ul>{filteredTodos.map(...)}</ul>;
}
// ✅ レンダー中に直接計算
function TodoList({ todos, filter }) {
const filteredTodos = todos.filter(t => t.status === filter);
return <ul>{filteredTodos.map(...)}</ul>;
}
5. トラブルシューティング
5.1 エフェクトが2回実行される
原因: Strict Mode での開発時の動作
// 開発環境では意図的に2回実行される
useEffect(() => {
console.log('Setup'); // 2回出力される
return () => console.log('Cleanup');
}, []);
解決策: クリーンアップを正しく実装する
5.2 無限ループ
原因: エフェクト内で依存値を更新
// ❌ 無限ループ
useEffect(() => {
setCount(count + 1); // state を更新 → 再レンダー → エフェクト再実行
}, [count]);
// ✅ 更新関数を使う
useEffect(() => {
setCount(c => c + 1);
}, []); // 1回だけ実行
5.3 クリーンアップのタイミング
useEffect(() => {
// セットアップ
const subscription = subscribe(id);
return () => {
// クリーンアップは以下のタイミングで実行:
// 1. 依存配列が変わった時(新しいセットアップの前)
// 2. コンポーネントがアンマウントされた時
subscription.unsubscribe();
};
}, [id]);
6. まとめ
この記事で解説した内容は、公式ドキュメントとfacebook/react リポジトリの以下のファイルに基づいています:
useEffect のエントリポイント
-
packages/react/src/ReactHooks.js-
useEffectのエクスポート関数
-
コア実装
-
packages/react-reconciler/src/ReactFiberHooks.js-
mountEffect/updateEffect: Effect の登録 -
mountEffectImpl/updateEffectImpl: Effect オブジェクトの作成 -
pushSimpleEffect: 循環リストへの追加
-
Effect フラグ
-
packages/react-reconciler/src/ReactHookEffectTags.js-
Passive/Layout/Insertionフラグの定義
-
Effect の実行
-
packages/react-reconciler/src/ReactFiberWorkLoop.js-
flushPassiveEffects: Passive Effects の実行タイミング
-
-
packages/react-reconciler/src/ReactFiberCommitEffects.js-
commitHookEffectListMount: セットアップの実行
-
-
useEffectは外部システムとコンポーネントを同期するためのフック - 内部的には
HookPassiveフラグを使い、ブラウザ描画後に非同期で実行 - Effect オブジェクトは循環リストで管理され、依存配列の変更で再実行が決まる
- クリーンアップはセットアップと「対称的」に実装すべき
- Strict Mode での二重実行はバグではなく、クリーンアップの検証
使い分けの指針: 外部システムとの同期が必要な場合は useEffect、props/state から計算できる値には不要。