useEffect 完全ガイド — 「使わない」判断から始める
結論
useEffect は「React の外側にあるシステムと同期する」ためだけの道具。 それ以外の用途で書いた useEffect は、ほぼ全部バグの温床になる。
外側のシステムとは:
- ブラウザ API(
addEventListener、IntersectionObserver、matchMedia) - タイマー(
setInterval、setTimeout) - ネットワーク(fetch、WebSocket)
- サードパーティライブラリのインスタンス(地図、エディタ、チャート)
- Electron の IPC、Capacitor のネイティブプラグイン
逆に言えば、画面に表示する値を計算するために useEffect を書いていたら、それは間違い。
1. まず「要らない useEffect」を消す
React 公式が "You Might Not Need an Effect" というドキュメントをわざわざ用意しているくらい、ここが最大のハマりどころ。典型パターンを潰していく。
❌ パターン①: 派生 state を useEffect で作る
// ダメ: レンダリングが2回走る + 一瞬 fullName が空になる
function Profile({ firstName, lastName }) {
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
return <h1>{fullName}</h1>;
}
// ✅ 正解: レンダリング中に計算するだけ
function Profile({ firstName, lastName }) {
const fullName = `${firstName} ${lastName}`;
return <h1>{fullName}</h1>;
}
既存の props / state から計算できる値は、state にしてはいけない。 計算が本当に重い場合だけ useMemo を挟む。
// 数千件のフィルタリングなど、実測して重かった場合のみ
const visibleTodos = useMemo(
() => todos.filter((t) => !t.done),
[todos]
);
❌ パターン②: イベントハンドラでやるべき処理
// ダメ: 「なぜこれが動くのか」が追えないコードになる
useEffect(() => {
if (isSubmitted) {
showToast('保存しました');
router.push('/list');
}
}, [isSubmitted]);
// ✅ 正解: 「ボタンを押したから起きること」はハンドラに書く
const handleSubmit = async () => {
await save(data);
showToast('保存しました');
router.push('/list');
};
判断基準はシンプル:
「ユーザーが何かしたから起きること」→ イベントハンドラ
「この画面が表示されているから起きること」→ useEffect
❌ パターン③: props が変わったら state をリセット
// ダメ: 一瞬だけ前のユーザーのコメントが表示される
useEffect(() => {
setComment('');
}, [userId]);
// ✅ 正解: key を渡してコンポーネントごと作り直させる
<ProfileForm key={userId} userId={userId} />
key が変わると React は state を丸ごと捨てて新しいインスタンスを作る。useEffect より速く、確実。
❌ パターン④: 親への通知
// ダメ: 子 → 親の更新が1フレーム遅れる
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
onToggle(isOpen);
}, [isOpen]);
// ✅ 正解: 同じイベントの中で両方やる
const handleClick = () => {
const next = !isOpen;
setIsOpen(next);
onToggle(next);
};
2. 正しい useEffect: 外部システムとの同期
ここからが本来の用途。
イベントリスナーの購読
function useWindowSize() {
const [size, setSize] = useState({ w: window.innerWidth, h: window.innerHeight });
useEffect(() => {
const handleResize = () => {
setSize({ w: window.innerWidth, h: window.innerHeight });
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize); // 必須
}, []);
return size;
}
Electron の IPC
useEffect(() => {
const handleFileOpened = (_event, filePath) => {
dispatch({ type: 'fileOpened', filePath });
};
const unsubscribe = window.electronAPI.onFileOpened(handleFileOpened);
return () => unsubscribe(); // preload 側で off() を返す設計にしておく
}, []);
preload で ipcRenderer.on() を露出するときは、必ず解除用の関数を返す形にする。返さないとリスナーが積み上がって、1回のファイルオープンで N 回ハンドラが走る。
Capacitor のネイティブイベント
useEffect(() => {
let listener;
(async () => {
listener = await App.addListener('appStateChange', ({ isActive }) => {
if (isActive) refetch();
});
})();
return () => {
listener?.remove();
};
}, [refetch]);
Capacitor の addListener は Promise を返すので、cleanup 時点でまだ解決していない可能性がある。上のように変数で受けるか、ignore フラグを併用する。
サードパーティライブラリのライフサイクル
useEffect(() => {
const editor = new CodeMirror(ref.current, { value: initialValue });
return () => editor.destroy();
}, []); // マウント時に生成、アンマウント時に破棄
3. クリーンアップ関数を「必ず」書く
useEffect は、cleanup を書かないと壊れる前提で設計されている。 開発モードの StrictMode では、React が意図的に「マウント → アンマウント → 再マウント」を実行する。これは cleanup 漏れを検出するための機能。
[dev] effect実行 → cleanup実行 → effect実行
つまり cleanup が正しく書かれていれば、2回実行されても何も問題が起きない。もし2回実行で挙動がおかしくなるなら、それは本番でも起きうるバグ(ページ遷移の戻る操作など)。
非同期処理の race condition
これが一番怖い。
// ❌ ダメ: userId が素早く 1 → 2 と変わると、1 のレスポンスが後着して上書きすることがある
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
// ✅ 正解: ignore フラグで古いレスポンスを捨てる
useEffect(() => {
let ignore = false;
fetchUser(userId).then((data) => {
if (!ignore) setUser(data);
});
return () => {
ignore = true;
};
}, [userId]);
AbortController を使えば通信そのものもキャンセルできる:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => res.json())
.then(setUser)
.catch((err) => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, [userId]);
実務では、この手のデータ取得は自前で書かず TanStack Query / SWR に任せるのが正解。キャッシュ、重複排除、リトライ、race condition 対策が全部入っている。useEffect で fetch を書くのは、ライブラリを入れられない事情があるときだけ。
4. 依存配列を正しく理解する
依存配列は「実行タイミングを制御するスイッチ」ではなく、「effect の中で使っている外部の値の一覧」。ここを取り違えると詰む。
useEffect(() => {
// ...
}, [a, b]); // a か b が変わったら cleanup → 再実行
useEffect(() => {}, []); // マウント時のみ
useEffect(() => {}); // 毎レンダリング(ほぼ使わない)
比較は Object.is(浅い比較)
// ❌ options は毎レンダリング新しいオブジェクト → 毎回 effect が再実行される
function Chat({ roomId }) {
const options = { serverUrl: 'https://...', roomId };
useEffect(() => {
const conn = createConnection(options);
conn.connect();
return () => conn.disconnect();
}, [options]); // 実質「毎回」
}
// ✅ オブジェクトの生成を effect の中に入れる
function Chat({ roomId }) {
useEffect(() => {
const options = { serverUrl: 'https://...', roomId };
const conn = createConnection(options);
conn.connect();
return () => conn.disconnect();
}, [roomId]); // プリミティブだけを依存に置く
}
依存配列にはできる限りプリミティブ(string / number / boolean)だけを置く。 オブジェクトや関数を置きたくなったら、まず effect の中に移動できないか考える。
依存を「消す」のではなく「不要にする」
lint の警告を握りつぶすのは最悪手。
// ❌ 絶対にやらない
useEffect(() => {
doSomething(count);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
setState の更新関数形式を使えば、依存を減らせることが多い:
// ❌ count が依存に必要 → 毎秒 interval が張り直される
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);
}, []);
5. 「最新の値は読みたいが、再実行はしたくない」問題
よくあるのがこれ。ログ送信やコールバック呼び出しで、最新の props は読みたいが、それが変わるたびに接続を張り直したくはない。
// ❌ theme が変わるたびに再接続されてしまう
useEffect(() => {
const conn = createConnection(roomId);
conn.on('connected', () => showNotification('接続', theme));
conn.connect();
return () => conn.disconnect();
}, [roomId, theme]);
解法A: ref に逃がす(どのバージョンでも使える)
const themeRef = useRef(theme);
useEffect(() => {
themeRef.current = theme;
});
useEffect(() => {
const conn = createConnection(roomId);
conn.on('connected', () => showNotification('接続', themeRef.current));
conn.connect();
return () => conn.disconnect();
}, [roomId]); // theme は依存に不要
解法B: useEffectEvent(新しめの React で利用可)
import { useEffectEvent } from 'react';
const onConnected = useEffectEvent(() => {
showNotification('接続', theme); // 常に最新の theme を読む
});
useEffect(() => {
const conn = createConnection(roomId);
conn.on('connected', onConnected);
conn.connect();
return () => conn.disconnect();
}, [roomId]); // onConnected は依存に入れない
導入前に使っている React のバージョンで安定版として提供されているか確認したほうがいい。使えないなら解法A で十分。
6. useLayoutEffect との使い分け
| 実行タイミング | 用途 | |
|---|---|---|
useEffect |
ブラウザの描画後(非同期) | 通常はこちら |
useLayoutEffect |
DOM 更新後・描画前(同期) | レイアウト計測して即座に位置を直す場合 |
// ツールチップの高さを測って、はみ出すなら上下反転する
useLayoutEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setTooltipHeight(height); // 描画前に確定するのでチラつかない
}, []);
useLayoutEffect はメインスレッドをブロックするので、計測 → 即反映が必要な場合以外は使わない。
7. 実務チェックリスト
useEffect を書いたら、以下を自問する。
- この処理、レンダリング中の計算で済まないか?(派生 state になっていないか)
- この処理、イベントハンドラに書くべきではないか?
- cleanup 関数を返しているか?
- StrictMode で2回実行されても正しく動くか?
-
非同期処理なら
ignoreフラグかAbortControllerがあるか? - 依存配列にオブジェクト・関数が入っていないか?
- eslint の警告を無視していないか?
まとめ
- useEffect は「React 外部との同期」専用。表示用の値の計算には使わない
- 消せる useEffect が、コードベースには想像以上に多い
- cleanup は義務。StrictMode の二重実行は cleanup 漏れ検出のための機能
- 依存配列は制御スイッチではなく、使っている値の宣言
- データ取得は原則ライブラリに任せる
まず「この useEffect は消せないか」を疑う。 それが useEffect と付き合う一番の近道。