ReactReact [リアクト]Meta開発のUIライブラリでコンポーネントを開発していると、外部APIAPI [エーピーアイ]Application Programming Interface。異なるソフトウェア間の接続口からのデータ取得、タイマー処理の登録、手動でのDOMDOM [ドム]Document Object Model。HTMLの木構造の表現操作など、「画面の描画(レンダリング)以外の処理」を実行したい場面によく遭遇します。
このようなレンダリングに伴う外部システムとの同期処理を扱うための標準フックが useEffect です。
しかし、依存配列の指定ミスによる無限ループや、クリーンアップ関数の書き忘れによるリソースの浪費や予期しないバグ、さらには「本来 useEffect を使うべきでない場面での多用」など、実務でつまずきやすい落とし穴も少なくありません。
本記事では、useEffect の基本構文から依存配列の3つのパターン、クリーンアップ関数の仕組み、実務で頻出するアンチパターンとその回避策まで、サンプルコードを交えて詳しく解説します。
Reactの useEffect とは?(副作用の同期)
useEffect は、関数コンポーネント内で 副作用(Side Effect) を実行するための React フックです。
副作用(Side Effect)とは何か?
Reactのコンポーネントは、原則として「同じ props と state を受け取ったら、常に同じ JSX を返す」という 純粋関数(Pure Function) として動作することが推奨されています。
しかし、実際のWebアプリケーションでは、純粋な計算だけでは完結しない以下のような処理が必要です。
- サーバーから非同期でデータを取得(fetch / axios など)する
- ブラウザの
document.titleやlocalStorageを更新する setIntervalやsetTimeoutなどのタイマーを起動するwindow.addEventListenerでグローバルイベントを監視する
これら「コンポーネントのレンダリング計算の外側に影響を与える処理」を総称して 副作用(Side Effect) と呼びます。
useEffect が果たす役割
useEffect は、単に処理の実行タイミングを制御するための仕組みではなく、コンポーネントの表示に伴って外部システムと同期するため のフックです。
useEffect を利用すると、コンポーネントのレンダリング結果が DOM に反映(コミット)された後に、副作用を実行できます。通常、React はブラウザが画面を再描画(ペイント)した後に Effect を実行するため、画面表示を妨げずに外部 API との通信やイベントリスナーの登録などを行えます。
ただし、ユーザー操作に起因する Effect などでは、ブラウザの再描画前に実行される場合もあります。また、DOM の位置やサイズを測定して視覚的な更新を行うなど、画面描画前に同期的に処理する必要がある場合は useLayoutEffect を使用します。
useEffect の基本構文と3つの実行パターン
useEffect の基本構文と、実行頻度を制御する 依存配列(Dependency Array) の指定方法を解説します。
基本構文
useEffect は、第1引数に「実行したい処理(副作用関数)」、第2引数に「依存配列」を渡します。
import { useEffect } from "react";
useEffect(() => { // 副作用として実行したい処理
return () => { // クリーンアップ関数(任意) };}, [/* 依存する値 */]);第2引数の 依存配列に何を渡すか によって、エフェクトが実行されるタイミングが大きく3通りに変化します。なお、useEffect は React Hooks の基本ルールとして、コンポーネントまたはカスタムフックの トップレベル で呼び出す必要があり、条件分岐やループの中で呼び出すことはできません。
パターン1: 依存配列を省略(初回+毎コミット後に実行)
第2引数の配列自体を省略すると、コンポーネントの初回マウント後、および再レンダリングによる コミットのたび にエフェクトが実行されます(自身の state や props だけでなく、親コンポーネントの再レンダリングや Context の変更などでも実行されます)。
import { useState, useEffect } from "react";
export function CounterEveryRender() { const [count, setCount] = useState(0);
useEffect(() => { // 描画されるたびに毎回実行される console.log(`レンダリング完了: count は ${count} です`); });
return ( <button onClick={() => setCount(count + 1)}> カウント: {count} </button> );}このパターンは、再レンダリングのたびに重い処理が走るため、パフォーマンス低下や予期しない多重実行の原因になりやすい点に注意が必要です。通常の実務では、後述のパターン2またはパターン3を使用することがほとんどです。
パターン2: 空の依存配列 [] を指定(マウント時のセットアップ)
第2引数に空の配列 [] を渡すと、そのコンポーネントがマウントされたときにエフェクトが実行されます。同じコンポーネントがマウントされたままであれば、通常の再レンダリングでは再実行されません。ただし、アンマウント後に再マウントされた場合は再び実行されます。
また、開発環境で Strict Mode が有効な場合は、Effect のクリーンアップが正しく実装されているかを検証するため、追加のセットアップ・クリーンアップサイクルが実行されます(そのため、開発環境ではデータ取得のリクエストが2回発生するように見える場合があります)。
import { useState, useEffect } from "react";
export function UserProfile() { const [user, setUser] = useState(null);
useEffect(() => { const controller = new AbortController();
// マウント時にAPIからユーザー情報を取得する fetch("https://api.example.com/user/current", { signal: controller.signal, }) .then((res) => { if (!res.ok) { throw new Error(`HTTP Error: ${res.status}`); } return res.json(); }) .then((data) => setUser(data)) .catch((err) => { if (err.name !== "AbortError") { console.error("通信エラー:", err); } });
return () => { controller.abort(); }; }, []); // 空の依存配列
if (!user) return <p>読み込み中...</p>; return <div>ようこそ、{user.name} さん</div>;}初期表示に必要な外部システムとの同期や、イベントリスナー・タイマーの登録など、コンポーネントがマウントされた際にセットアップしたい処理に適したパターンです。
パターン3: 依存配列に値を指定(指定した値の変更時のみ実行)
第2引数の配列に state や props などの変数を指定すると、 初回マウント時 に加えて、 配列内の値が前回のレンダリングから変化したときのみ エフェクトが再実行されます。
import { useState, useEffect } from "react";
export function SearchResults({ query }) { const [results, setResults] = useState([]);
useEffect(() => { const controller = new AbortController(); setResults([]); // 新しい検索条件の取得開始時に古い結果をクリア
// query が変化したときだけAPIを呼び出す fetch(`https://api.example.com/search?q=${encodeURIComponent(query)}`, { signal: controller.signal, }) .then((res) => { if (!res.ok) { throw new Error(`HTTP Error: ${res.status}`); } return res.json(); }) .then((data) => setResults(data)) .catch((err) => { if (err.name !== "AbortError") { console.error("通信エラー:", err); } });
// query が変わった時やアンマウント時に古いリクエストを中断して競合状態を防ぐ return () => { controller.abort(); }; }, [query]); // query に依存
return ( <ul> {results.map((item) => ( <li key={item.id}>{item.name}</li> ))} </ul> );}Reactは内部的に Object.is を使って依存配列の値が変化したかどうかを比較します。値が変わっていない場合はエフェクトの実行をスキップするため、不要なAPI通信や処理の重複を防げます。
[!NOTE] 実務におけるデータ取得の選択肢: React 公式ドキュメントでは、
useEffect内で直接データ取得を行う場合、キャッシュ、重複リクエストの排除、ウォーターフォール、競合状態(Race Condition)などを自分で考慮する必要があると説明されています。実務のアプリケーションでは、Next.jsNext.js [ネクストジェーエス]React製のフルスタックWebフレームワーク などのフレームワークが提供するデータ取得機能や、TanStack Queryquery [クエリー / クエリ]データベースへの問い合わせ文 などのデータ取得ライブラリを利用する選択肢も検討してください。
クリーンアップ関数(cleanup)の仕組みと役割
useEffect のコールバック関数から別の関数を return すると、その返り値は クリーンアップ関数 として扱われます。
なぜクリーンアップが必要なのか?
コンポーネントがアンマウントされた後もタイマーが動き続けたり、不要になったイベントリスナーが登録されたままになったりすると、不要な処理が継続し、メモリや CPUCPU [シーピーユー]Central Processing Unit。コンピュータの頭脳となる中央演算処理装置 リソースの浪費、予期しない動作につながる可能性があります。
クリーンアップ関数を正しく実装することで、不要になったリソースを解放できます。
クリーンアップが実行されるタイミング
クリーンアップ関数が呼び出されるタイミングは以下の2つです。
- コンポーネントが画面から消えるとき(アンマウント時)
- 依存配列の値が更新され、次の新しいエフェクトが実行される直前
「新しいエフェクトを実行する前に、前回の古いエフェクトを片付ける」という順序で動作するため、常にクリーンな状態が保たれます。
具体例1: setInterval タイマーの解除
定期実行タイマーをセットした場合、クリーンアップ関数で clearInterval を呼び出します。
import { useState, useEffect } from "react";
export function Timer() { const [seconds, setSeconds] = useState(0);
useEffect(() => { const timerId = setInterval(() => { setSeconds((prev) => prev + 1); }, 1000);
// クリーンアップ関数でタイマーを破棄 return () => { clearInterval(timerId); }; }, []);
return <div>経過時間: {seconds} 秒</div>;}クリーンアップ関数を書き忘れると、コンポーネントがアンマウントされた後もタイマーが動き続け、ブラウザのリソースを消費し続ける可能性があります。また、依存配列の指定によっては、Effect が再実行されるたびにタイマーが追加で登録され、複数のタイマーが同時に動作する原因にもなります。
具体例2: イベントリスナーの登録と解除
window や document などのグローバルオブジェクトにイベントリスナーを登録する場合も、クリーンアップが必須です。
import { useState, useEffect } from "react";
export function WindowWidth() { const [width, setWidth] = useState(0);
useEffect(() => { const handleResize = () => { setWidth(window.innerWidth); };
// クライアント側でのマウント直後に初期値を設定 handleResize(); window.addEventListener("resize", handleResize);
// クリーンアップ関数でリスナーを解除 return () => { window.removeEventListener("resize", handleResize); }; }, []);
return <div>現在の画面幅: {width}px</div>;}[!NOTE] SSR環境での注意点: Next.js などの SSR(サーバーサイドレンダリング)環境では、サーバー側に
windowオブジェクトが存在しないため、useState(window.innerWidth)のように初期化するとレンダリング時にReferenceErrorが発生する可能性があります。初期値は0(またはundefined)にしておき、クライアント側で実行されるuseEffect内で値を設定してください。なお、画面幅のような外部ストアの購読にはuseSyncExternalStoreを使う方法もあります。
具体例3: AbortController による非同期通信のキャンセル
ユーザーが検索条件を素早く切り替えた場合、古いリクエストが遅れて返ってきて画面を古いデータで上書きしてしまう「競合状態(Race Condition)」が発生することがあります。
ブラウザ標準の AbortController をクリーンアップと組み合わせることで、進行中のリクエストをキャンセルできます。
import { useState, useEffect } from "react";
export function UserDetail({ userId }) { const [user, setUser] = useState(null);
useEffect(() => { const controller = new AbortController();
fetch(`https://api.example.com/users/${userId}`, { signal: controller.signal, }) .then((res) => { if (!res.ok) { throw new Error(`HTTP Error: ${res.status}`); } return res.json(); }) .then((data) => { setUser(data); }) .catch((err) => { // キャンセルによるエラーは無視する if (err.name !== "AbortError") { console.error("通信エラー:", err); } });
// userId が変わった時、またはアンマウント時に直前の fetch を中断 return () => { controller.abort(); }; }, [userId]);
if (!user) return <p>読み込み中...</p>; return <h2>{user.name}</h2>;}fetch() は HTTPHTTP [エイチティーティーピー]HyperText Transfer Protocol。Webでのデータ転送プロトコル 404 や 500 などのエラーレスポンスを受け取っても通常は Promise を reject しないため、res.ok を確認してエラーを投げることで、通信エラーと HTTP エラーを適切に区別できます。また、クリーンアップで controller.abort() を呼ぶことで、進行中の fetch リクエストを中断でき、古いリクエストの結果が後から返ってきて新しい表示内容を上書きする競合状態を防げます(なお、AbortController でキャンセルできるのは AbortSignal に対応した非同期処理に限られます)。
React 18 の Strict Mode でエフェクトが2回動く理由
React 18 以降では、アプリケーションのルートを <React.StrictMode> で囲んでいる場合、開発環境で Effect の初回実行前に追加の「セットアップ → クリーンアップ」サイクルが実行されます。具体的には 「セットアップ → クリーンアップ → セットアップ」 という順序になります。
これはバグではなく、クリーンアップ関数が正しく実装されているかを開発時に検証するための React の公式仕様です。実際にコンポーネントそのものをアンマウントして再マウントしているわけではありません。
本番環境(Production ビルド)ではこの追加サイクルは実行されず、通常どおり 1 回のみセットアップが実行されます。開発環境で 2 回実行されても、クリーンアップが正しく書かれていれば問題ありません。
実務でよくある落とし穴とアンチパターン
useEffect は強力なフックですが、間違った使い方をするとパフォーマンス劣化や無限ループを招きます。代表的な落とし穴と解決策を確認しましょう。
落とし穴1: 依存ステートを更新することによる無限ループ
エフェクトの中で state を更新し、その state を依存配列に指定すると、 「state更新 → レンダリング → エフェクト実行 → state更新…」 の無限ループが発生します。
// ❌ 悪い例: 無限ループになるコードuseEffect(() => { setCount(count + 1);}, [count]);関数型更新を利用してタイマー処理の依存関係を減らす
前回の state を基に値を更新したい場合は、関数型更新(Functional Update) が有効です。特に、setInterval などのコールバック内で state を更新する場合、setCount((prev) => prev + 1) のように記述すれば、コールバック内で現在の count を直接参照する必要がなくなります。その結果、Effect の依存配列から count を取り除けます。
// ⭕ 良い例: タイマーのコールバック内で関数型更新を使い、count への依存をなくすuseEffect(() => { const timer = setInterval(() => { setCount((prev) => prev + 1); }, 1000);
return () => clearInterval(timer);}, []); // count に依存しないただし、Effect 内で現在の state を参照して外部システムと同期する必要がある場合は、依存配列に含めなければなりません。関数型更新は、依存配列に必要な値を無条件に省略するための方法ではない点に注意してください。
落とし穴2: オブジェクトや関数を依存配列に入れてしまう
React は依存配列の各要素について、前回のレンダリング時の値と Object.is を使って個別に比較します。オブジェクトや配列、関数は、内容が同じでも新しく生成されると参照が異なるため、変更されたものとして扱われます。
そのため、コンポーネント内で毎回生成されるオブジェクトや関数を依存配列に指定すると、不要なエフェクトの再実行につながる場合があります。
function BadComponent() { const [data, setData] = useState(null);
// レンダリングごとに毎回新しいオブジェクト参照が生成される const options = { page: 1, limit: 10 };
useEffect(() => { fetchData(options); }, [options]); // 毎回参照が異なるため、実質的に毎回実行される}この問題の解決策は以下の3つです。
- 必要なプリミティブ値だけを依存配列に指定し、オブジェクトは useEffect 内で組み立てる: Effect 内で使うオブジェクトが
pageやlimitなどの値に依存しているなら、オブジェクト自体を外側で作らず Effect の内側に移動し、依存するプリミティブ値だけを依存配列に渡します。function GoodComponent() {const [data, setData] = useState(null);const page = 1;const limit = 10;useEffect(() => {const options = { page, limit };fetchData(options).then(setData);}, [page, limit]);} - 不要な依存関係を取り除けないか見直す: 値がコンポーネントの props や state に依存しない定数であればコンポーネントの外へ移動するなど、まず不要なオブジェクトや関数の依存関係を作らない設計を検討します。
- useMemo / useCallback を使う: どうしてもコンポーネント内の値を基にしたオブジェクトや関数の参照を外側で維持したい場合に限り、メモ化フックで参照を固定します。
なお、JavaScript における変数の再代入や参照の基本については JavaScriptのvar・let・constの違い を、TypeScriptTypeScript [タイプスクリプト]JavaScriptに型システムを追加した言語 プロジェクトで定数オブジェクトの型推論を固定したい場合は as constの使い方 も参考にしてください。
落とし穴3: 「それ、本当に useEffect 必要?」
React 公式ドキュメントの「You Might Not Need an Effect(エフェクトは不要かもしれない)」でも強調されている通り、実務では useEffect を書かずに済むケース が多くあります。
代表的な2つのケースを確認しましょう。
ケースA: props や state から計算できる値
「props が変わったら別の state を更新する」という目的で useEffect を使うのはアンチパターンです。不要な再レンダリングが発生し、コードも複雑化します。
// ❌ 悪い例: useEffect で派生状態を同期しているfunction FullName({ firstName, lastName }) { const [fullName, setFullName] = useState("");
useEffect(() => { setFullName(`${firstName} ${lastName}`); }, [firstName, lastName]);
return <p>{fullName}</p>;}
// ⭕ 良い例: レンダリング中の通常の変数計算で十分function FullName({ firstName, lastName }) { const fullName = `${firstName} ${lastName}`; return <p>{fullName}</p>;}レンダリング中に直接計算すれば、余分な state やエフェクトを持たずに済み、同期ズレのバグも起きません。
ケースB: ユーザー操作に応じた処理
「ボタンがクリックされたらAPIにデータを送信する」などのユーザー操作に起因する処理は、useEffect ではなく イベントハンドラー(onClick など) の中に直接記述します。
useEffect は「画面に表示されたことによって発生する同期処理」のためのフックであり、「ユーザーがボタンを押した」という具体的なイベントのハンドリングには適していません。
useEffect 活用のまとめ
useEffect は処理の実行タイミングを汎用的に制御するためのフックではなく、コンポーネントの表示と外部システムを同期させるための仕組み です。適切に使いこなすための判断基準をまとめました。
| 依存配列の指定 | 実行タイミング | 主な用途 |
|---|---|---|
| 省略 | 初回 + 毎コミット後 | 特定の外部システムとの毎回の同期(必要な場合に限定) |
空配列 [] | 初回セットアップ時 | イベントリスナーやタイマーの登録 |
[a, b] | 初回 + 指定値の変更時 | 検索条件に応じたデータ取得、外部システムとの接続管理 |
実装前のセルフチェックリスト
- 外部システムとの同期か?: 画面の表示や外部APIとの連携ではなく、単なるデータの加工であればレンダリング中の通常計算で十分です。
- クリーンアップ関数はあるか?: タイマー、イベントリスナー、非同期通信の中断処理が漏れていないか確認しましょう。
- 依存配列に必要な変数がすべて含まれているか?: エフェクト内で参照しているリアクティブな値(props や state)を意図的に隠していませんか?
- イベントハンドラーで書けないか?: ユーザーの操作がトリガーなら、
onClickやonSubmitに処理を書きましょう。
これらを意識することで、予期せぬ無限ループや不要なリソースの残存がない、保守性の高い React アプリケーションを構築できます。
以上で本記事の解説を終わります。
よいITライフを!
人気記事
- 1
- 2
- 3
- 4
- 5
思考の整理というテーマを、実務ですぐに使える具体的な「技術」として落とし込んだ一冊です。