useEffect は React の中で最も使われ、同時に最も誤用される Hook です。「とりあえずデータフェッチに使う」「依存配列に何を入れるか分からない」「StrictMode で 2 回呼ばれてバグる」——こうした悩みは useEffect の本質を「副作用の同期」として理解できていないことに起因します。
本稿は React 18/19 時代の useEffect を体系的に整理し、依存配列・クリーンアップ・Race Condition・useLayoutEffect / useInsertionEffect / useSyncExternalStore との使い分け、そしてデータフェッチで useEffect を「使わない」選択肢までを 5,000 字超で解説します。実務で「これ useEffect でいいの?」と迷う場面の判断軸を持ち帰ってください。
関連記事として、Hooks 全体像はReact Hooks 完全実践ガイド、メモ化系の使い分けはuseMemo vs useCallback 完全比較を参照してください。本稿は useEffect 単体に絞った Hub 記事です。
そもそも「副作用(Side Effect)」とは何か
React 公式ドキュメントは useEffect を「コンポーネントを外部システムと同期させるための Hook」と定義しています。ここで言う外部システムとは、React が管理しない世界——DOM のスクロール位置、document.title、localStorage、WebSocket、サードパーティ製ライブラリ、ネットワーク、タイマーなどです。
レンダー中にやってはいけないこと
関数コンポーネントの本体(レンダーフェーズ)は「同じ props・state なら必ず同じ JSX を返す純粋関数」でなければなりません。次のコードは副作用をレンダー中に置いてしまっており、無限ループや SSR 失敗の原因になります。
// NG: レンダー中に副作用を実行
function BadComponent({ userId }) {
// document はサーバーに存在しない、毎レンダー実行される
document.title = `User ${userId}`;
fetch(`/api/users/${userId}`); // 毎レンダーごとにリクエスト
return <div>...</div>;
}
こうした「レンダー結果そのものに含めない処理」を、コミット後に安全に動かすのが useEffect の役目です。
useEffectの基本シグネチャ
import { useEffect } from "react";
useEffect(setup, dependencies?);
// setup: 副作用を実行する関数(返り値はクリーンアップ関数)
// dependencies: 依存値の配列 / 省略可 / 空配列 [] も可
useEffectが「何度も呼ばれる」前提を持つ
初心者がつまずく最大の罠は「useEffect は 1 回だけ実行される」という誤解です。実際には次の場面で実行されます。
- マウント直後(最初のコミット完了後)
- 依存配列のいずれかが
Object.isで「変わった」と判定された次のコミット後 - StrictMode 開発時は「マウント → アンマウント → 再マウント」が意図的に発生し、2 回呼ばれる
- 親が再マウント条件を満たすとき(
keyの変更など) - Hot Module Replacement(Vite/Next dev サーバー)で再評価されるとき
つまり useEffect は「同じ副作用を何度実行しても破綻しない」コードでなければなりません。これが本記事を通底する原則です。
依存配列の正しい指定と落とし穴
3パターンの依存配列を比較する
| 依存配列 | 実行タイミング | 主な用途 |
|---|---|---|
| 省略 | 毎レンダー後 | ほぼ使わない(意図がない限り NG) |
[] | マウント時のみ(+ StrictMode で再マウント時) | 1 回きりの初期化、subscribe & unsubscribe |
[a, b] | a または b が変わったとき | 外部システムと state/props の同期 |
依存に「使った値」をすべて入れる
React 公式が明示している鉄則は「setup 関数の中で読み取ったリアクティブな値はすべて依存配列に入れる」です。eslint-plugin-react-hooks の exhaustive-deps ルールはこれを機械的に検出します。
// OK: query を依存に入れているので変化に追随する
function SearchResult({ query }) {
const [items, setItems] = useState([]);
useEffect(() => {
let cancelled = false;
fetch(`/api/search?q=${encodeURIComponent(query)}`)
.then((r) => r.json())
.then((data) => { if (!cancelled) setItems(data); });
return () => { cancelled = true; };
}, [query]); // ← query を抜くと古い結果が残る
}
無限ループを生む典型パターン
// NG: 毎レンダーで新しい配列が生成され、依存判定で常に「変わった」と見なされる
function List({ ids }) {
const params = { ids, page: 1 }; // ← 新しいオブジェクト参照
useEffect(() => {
fetch("/api/list", { method: "POST", body: JSON.stringify(params) })
.then((r) => r.json())
.then(setItems); // setItems が再レンダーを誘発 → params が新生成 → 無限ループ
}, [params]);
}
対処は次の 3 つです。
- 依存配列にはオブジェクトではなくプリミティブ値(
ids.join(",")やpageなど)を入れる - オブジェクトを使うなら
useMemoで参照を安定化する - そもそも
useEffectの中で参照する必要があるか見直す(イベントハンドラへ移せないか)
「ESLint を黙らせる」アンチパターン
// NG: 依存を意図的に外して linter を黙らせる
useEffect(() => {
doSomething(value);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
exhaustive-deps を eslint-disable で握りつぶすのは、ほぼ必ず後でバグります。React 公式は次の代替を推奨しています。
- 依存値が「副作用のトリガーではなくただ参照したいだけ」なら、後述の Effect Event(useEffectEvent) を使う(experimental)
- 常に最新の値を参照したいだけなら
useRefに保存し、副作用本体は ref を読む - そもそもイベントハンドラに移せないか再検討する
クリーンアップ関数を必ず書く
useEffect の戻り値として返した関数が「クリーンアップ関数」です。次回 setup を実行する直前、およびアンマウント時に呼ばれます。「セットしたものは必ず後始末する」を徹底するだけでバグの 7 割は消えます。
イベントリスナーの登録解除
useEffect(() => {
const onScroll = () => setY(window.scrollY);
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
setInterval / setTimeout の解除
useEffect(() => {
const id = setInterval(() => setCount((c) => c + 1), 1000);
return () => clearInterval(id);
}, []);
WebSocket・EventSource の close
useEffect(() => {
const ws = new WebSocket(url);
ws.addEventListener("message", onMessage);
return () => {
ws.removeEventListener("message", onMessage);
ws.close();
};
}, [url]);
fetch を AbortController で中断する
useEffect(() => {
const ctrl = new AbortController();
fetch(`/api/users/${id}`, { signal: ctrl.signal })
.then((r) => r.json())
.then(setUser)
.catch((e) => {
if (e.name !== "AbortError") throw e;
});
return () => ctrl.abort();
}, [id]);
Race Conditionと「古い結果」問題
useEffect 内でデータフェッチをすると、必ず Race Condition(競合状態)を意識する必要があります。検索 UI で query = "ab" のリクエスト直後に query = "abc" のリクエストが投げられた場合、ネットワーク遅延の都合で先に投げた “ab” の結果が後に到着するケースがあります。これを setState してしまうと、表示が古い結果に巻き戻ります。
cancelled フラグ方式
useEffect(() => {
let cancelled = false;
(async () => {
const res = await fetch(`/api/search?q=${query}`);
const data = await res.json();
if (!cancelled) setItems(data);
})();
return () => { cancelled = true; };
}, [query]);
AbortController 方式(推奨)
AbortController は通信そのものを中断するため、無駄な転送が発生しません。React 公式の推奨も、フラグ方式より AbortController です。
useEffect(() => {
const ctrl = new AbortController();
fetch(`/api/search?q=${query}`, { signal: ctrl.signal })
.then((r) => r.json())
.then(setItems)
.catch((e) => { if (e.name !== "AbortError") setError(e); });
return () => ctrl.abort();
}, [query]);
StrictMode が「2回呼ぶ」のは仕様
React 18 以降、開発モードで <StrictMode> 配下のコンポーネントは初期マウント時に意図的に「マウント → アンマウント → 再マウント」を 1 回行います。これは「クリーンアップが正しく書けていれば、繰り返しマウントされても破綻しない」ことを保証するための機構です。
2回ログが出るのは正常
useEffect(() => {
console.log("mount");
return () => console.log("unmount");
}, []);
// 開発 StrictMode 下では:
// mount → unmount → mount と 3 行出力される
StrictMode で破綻しないチェックリスト
- fetch には AbortController を必ず付ける
- setInterval / setTimeout には clearInterval / clearTimeout を必ず書く
- サードパーティライブラリのインスタンス(Chart.js、Mapbox、Three.js など)は destroy / dispose を呼ぶ
- カウンタを
+1する処理は「同じ引数なら冪等」になっているか確認 - 外部 API への課金リクエストは
useEffectではなくイベントハンドラで送る
useEffect を「使うべきでない」ケース
React 公式ドキュメントには「You Might Not Need an Effect」という章があり、初心者がやりがちな「不要な useEffect」を 7 パターン挙げています。実務でレビューすると、Effect の 3〜4 割は消せます。
props から派生する state を同期するな
// NG: props から state を作って useEffect で同期
function Profile({ user }) {
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(user.firstName + " " + user.lastName);
}, [user]);
}
// OK: レンダー中に計算するだけ
function Profile({ user }) {
const fullName = user.firstName + " " + user.lastName;
}
イベントに対する処理は useEffect ではなくハンドラに書く
// NG: 購入完了 state を見て副作用を起こす
useEffect(() => {
if (purchased) showThanksToast();
}, [purchased]);
// OK: 購入ボタンのハンドラに直接書く
const handleBuy = async () => {
await api.buy();
showThanksToast();
setPurchased(true);
};
重い計算は useMemo に
useEffect で計算して state に保存するのは「2 度レンダー」が必ず発生する分の浪費です。同期的に計算できるなら useMemo を使うか、そもそもレンダー本体で計算します。詳細はuseMemo vs useCallback 完全比較を参照。
データフェッチは useEffect でやらない選択肢
2026 年現在、素の useEffect + fetch でデータ取得を書くべき場面はかなり減っています。Race Condition、ローディング状態、エラー、キャッシュ、再検証、Suspense 対応、SSR を全部自前で書くのは現実的でないからです。
主要な代替手段
| 方式 | 特徴 | useEffect が必要か |
|---|---|---|
| TanStack Query (React Query) | キャッシュ・再検証・Stale-While-Revalidate | 不要(内部で吸収) |
| SWR | 軽量、Vercel 公式 | 不要 |
| Next.js Server Components | サーバー側で fetch、クライアントへは結果だけ | 不要(そもそも書けない) |
| Remix loader / React Router data API | ルート単位の宣言的データロード | 不要 |
| 素の useEffect + fetch | 学習用・小規模ツール | 必要(全部自分で書く) |
React Query を使った場合の劇的な簡潔さ
import { useQuery } from "@tanstack/react-query";
function UserCard({ id }) {
const { data, isLoading, error } = useQuery({
queryKey: ["user", id],
queryFn: ({ signal }) =>
fetch(`/api/users/${id}`, { signal }).then((r) => r.json()),
});
if (isLoading) return <Skeleton />;
if (error) return <ErrorView />;
return <div>{data.name}</div>;
}
これ 1 つで「Race Condition 防御」「キャッシュ」「リトライ」「リフォーカス時の自動再取得」「AbortController 連携」がすべて入ります。useEffect を 50 行書いて頑張る価値があるのは、よほど特殊な要件だけです。
useEffect の仲間 4種類を整理する
React には「副作用」系の Hook が useEffect の他に 3 つあり、目的が明確に分かれています。間違えるとSSR 警告やフラッシュが起きます。
| Hook | 実行タイミング | 使いどころ |
|---|---|---|
useEffect | ブラウザの描画後(非同期) | サブスクライブ、外部システム同期、ログ送信 |
useLayoutEffect | DOM 反映後・描画前(同期) | レイアウト計測 → 再レンダー、ツールチップ位置調整 |
useInsertionEffect | DOM 変異前 | CSS-in-JS ライブラリ作者用、通常アプリでは使わない |
useSyncExternalStore | レンダー時に外部ストアを読む | Redux / Zustand 等のストア購読、concurrent safe |
useLayoutEffect が必要なとき
// ツールチップの位置を実測して反映するケース
function Tooltip({ targetRef }) {
const [top, setTop] = useState(0);
useLayoutEffect(() => {
const rect = targetRef.current.getBoundingClientRect();
setTop(rect.top - 40); // ← この同期更新を「描画前」に行いたい
}, [targetRef]);
return <div style={{ top }}>...</div>;
}
useEffect でやると一瞬「位置 0 でフラッシュ」が見えてしまいます。ただし useLayoutEffect はメインスレッドをブロックするため、必要なときだけに留めるのが鉄則です。
useSyncExternalStore で外部ストアを安全に購読
import { useSyncExternalStore } from "react";
function useWindowWidth() {
return useSyncExternalStore(
(cb) => {
window.addEventListener("resize", cb);
return () => window.removeEventListener("resize", cb);
},
() => window.innerWidth,
() => 0 // SSR 用のスナップショット
);
}
古い useEffect + setState の組み合わせは Concurrent Rendering でtearing(コンポーネント間で値がズレる)が起きます。外部ストア購読は 2026 年現在 useSyncExternalStore 一択です。
カスタムフックに切り出して再利用する
useEffect ロジックの使い回しは必ずカスタムフックとして切り出します。コンポーネント内に直接 useEffect を 5 個も並べると読解性とテスト性が崩壊するためです。
useInterval — 安全に setInterval する
import { useEffect, useRef } from "react";
export function useInterval(callback, delay) {
const savedCallback = useRef(callback);
useEffect(() => { savedCallback.current = callback; }, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
useOnlineStatus — オンライン状態を監視
export function useOnlineStatus() {
const [online, setOnline] = useState(() =>
typeof navigator === "undefined" ? true : navigator.onLine
);
useEffect(() => {
const up = () => setOnline(true);
const down = () => setOnline(false);
window.addEventListener("online", up);
window.addEventListener("offline", down);
return () => {
window.removeEventListener("online", up);
window.removeEventListener("offline", down);
};
}, []);
return online;
}
useDocumentTitle — タイトル同期の超薄ラッパ
export function useDocumentTitle(title) {
useEffect(() => {
const prev = document.title;
document.title = title;
return () => { document.title = prev; };
}, [title]);
}
useEffect のデバッグとテスト戦略
デバッグの定石
- console.log を setup / cleanup 両方に置く: 呼ばれた順を確かめるのが最速
- React DevTools の「⚙ → Highlight updates when components render」で過剰再レンダーを可視化
- 「依存配列に意図しない値が入っていないか」を
useRefで前回値を覚えて diff 出力 why-did-you-renderライブラリで「なぜ再レンダーされたか」を追跡- Chrome DevTools の Performance タブで「同期 effect」がメインスレッドを止めていないか確認
テストでの扱い方
import { renderHook, waitFor } from "@testing-library/react";
test("useUser fetches and returns user", async () => {
const { result } = renderHook(() => useUser(1));
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data.id).toBe(1);
});
act 警告が出る場合は、「Effect 内の setState を await し損ねている」サインです。waitFor や findBy* を使って解消します。
よくある質問(FAQ)
Q. 依存配列を空にして 1 回だけ実行したいのですが、ESLint が怒ります
A. ESLint の指摘はほぼ正しいです。「1 回だけ」と思っている処理が、本当は props の変化に追随すべきというケースが大半です。それでも 1 回にしたい場合は、そもそも useEffect ではなくモジュールトップレベルやイベントハンドラに置けないかを検討してください。どうしても必要なら、その値は「mutable な ref」に格納して依存から外す形が安全です。
Q. StrictMode で 2 回呼ばれるのが嫌で外していいですか?
A. 外さないでください。StrictMode で 2 回呼ばれて困るコードは、本番でも別の理由(再マウント・HMR・キー変更)で 2 回呼ばれます。クリーンアップを正しく書けば 2 回呼ばれても問題は出ません。
Q. async function を直接 useEffect に渡せますか?
A. 渡せません。async 関数は Promise を返しますが、useEffect の戻り値はクリーンアップ関数であるべきだからです。中で IIFE か関数定義をして呼び出すのが定石です。
useEffect(() => {
(async () => {
const res = await fetch("/api/me");
setMe(await res.json());
})();
}, []);
Q. componentDidMount / componentDidUpdate / componentWillUnmount との対応は?
A. 1 対 1 対応で考えるのは古い発想です。useEffect はライフサイクルメソッドの置き換えではなく「同期処理」のための Hook です。「マウント時にだけ実行」「アンマウント時にだけ実行」と分けて考えるより、「この外部システムを、どの値に追随させたいのか」を起点に依存配列を決めると自然に書けます。
Q. Server Components で useEffect は使えますか?
A. 使えません。Server Components はクライアント側のライフサイクルを持たないため、Hooks 全般が使えない仕様です。"use client" を付けたクライアントコンポーネントに切り出すか、データ取得自体をサーバー側の async 関数として書き直してください。
Q. useEffect が呼ばれない場合のチェックリストは?
- そのコンポーネント自体が描画されているか(React DevTools で確認)
- 依存配列の値が
Object.isで本当に変わっているか(オブジェクトや配列は参照が同じなら未変化と判定) - 親が
key変更で再マウントしていないか - 条件分岐の中に
useEffectを書いていないか(Hooks のルール違反)
Q. 学習リソースを体系的に追いたいです
A. 公式の「Synchronizing with Effects」「You Might Not Need an Effect」を一度通読することを最も強く推奨します。書籍より公式の例の方が現代的です。実務での詰まりポイントを聞ける環境が欲しい場合は、現役エンジニアに直接質問できるスクール(テックアカデミーのメンタリング型、侍エンジニアのマンツーマン型、DMM WEBCAMPのチーム学習型)を検討してください。フロントエンド/React 案件で実務に出たい方は、フリーランス向けのレバテックフリーランスで React 案件の現在の単価レンジを把握しておくとキャリア戦略が立てやすいです。
useEffect 設計チェックリスト
- その処理は本当に「外部システムとの同期」か? イベントハンドラに移せないか?
- 依存配列に「使った値」をすべて入れているか?
exhaustive-depsを disable していないか? - クリーンアップ関数で「セットしたもの」を後始末しているか?
- fetch には
AbortControllerを付けているか? - StrictMode 下で 2 回マウントされても破綻しないか?
- 「props から派生する値」を
setStateしていないか?(計算で済むなら計算) - 外部ストア購読は
useSyncExternalStoreを使っているか? - レイアウト計測なら
useLayoutEffectを使っているか?(useEffectではフラッシュする) - データフェッチを useEffect で書く前に、React Query / SWR / Server Components で済まないか確認したか?
このチェックリストを 1 件ずつ通せば、useEffect 起因のバグの 9 割は予防できます。useEffect は「便利な万能 Hook」ではなく、「外部世界との同期 API」として節度を持って使うものだと意識を切り替えてください。
まとめ:useEffect を「使わないで済ます技術」が一番強い
useEffect の真の習熟は、書ける量ではなく「書かずに済ませた量」で測れます。React 公式が「You Might Not Need an Effect」をわざわざ用意したのも、初心者ほど Effect を使い過ぎてバグを生むからです。本稿で扱った以下の論点を、レビュー時のセルフチェックに使ってください。
- 副作用 = 「外部システムとの同期」、それ以外なら Effect を疑う
- 依存配列は
exhaustive-depsに従う。disable は最終手段 - クリーンアップは 必ず 書く。StrictMode と Concurrent Rendering を前提に
- fetch は AbortController で中断。Race Condition は cancelled フラグでも防御
- 外部ストアは
useSyncExternalStore、レイアウト計測はuseLayoutEffect - データフェッチは React Query / SWR / Server Components が第一選択
関連 Hooks の体系的な復習はReact Hooks 完全実践ガイドを、メモ化系 Hook の使い分けはuseMemo vs useCallback 完全比較を参照してください。useEffect を制する者は React を制します。Happy hacking!

コメント