useEffect の中で使う return(クリーンアップ関数)は、コンポーネントのアンマウント時や、次のエフェクトが実行される前に「前回の処理を片付ける」ための重要な仕組みである。
1. なぜクリーンアップ(return)が必要か
useEffect 内で外部システムとの接続やタイマーの設定を行った場合、それらを破棄しないとメモリーリークや意図しない重複動作の原因になる。
たとえば、次の処理を行う場合は必ずクリーンアップが必要となる。
-
setInterval/setTimeoutの解除 - イベントリスナーの削除
- 外部サービス(WebSocketなど)のサブスクリプション解除
- 非同期処理(APIリクエストなど)のキャンセル
基本的な書き方
useEffect(() => {
// 1. 副作用のセットアップ
const id = setInterval(() => {
console.log("tick");
}, 1000);
// 2. クリーンアップ関数を return する
return () => {
clearInterval(id);
};
}, []);
-
return () => {}は関数を返すだけで、実行はReactに委ねられる。 -
returnは JSX を返す場所ではなく、後始末用の関数を返す場所である。
2. 依存配列(deps)とクリーンアップの実行タイミング
クリーンアップが「いつ実行されるか」は、依存配列の指定方法によって完全に変わる。
2.1 依存配列なし:毎回のレンダリングで実行
useEffect(() => {
console.log('effect');
return () => console.log('cleanup');
}); // 依存配列なし
- タイミング: 毎回のレンダリング後にエフェクトが走る直前、およびアンマウント時。
- 注意点: 頻繁にクリーンアップが走るため、負荷の高い処理を入れるとパフォーマンス低下の原因になる。
2.2 空の配列 []:マウント・アンマウント時のみ
useEffect(() => {
console.log('mounted');
return () => console.log('unmounted');
}, []);
- タイミング: コンポーネントが最初に画面に表示された(マウント)時に1回実行され、画面から消えた(アンマウント)時にクリーンアップが1回実行される。
💡 補足:開発環境(Strict Mode)で2回実行される理由
React 18以降、開発環境では[]を指定していても「マウント → アンマウント → マウント」の順でエフェクトが2回実行される。これはバグではなく、「コンポーネントが破棄されたときに、正しくクリーンアップ処理が行われているか」をReactがテストしているためである。本番環境では1回しか実行されないので気にする必要はない。
2.3 依存値あり [dep]:値が変わるたびに実行
useEffect(() => {
console.log('effect', userId);
return () => console.log('cleanup', userId);
}, [userId]);
-
タイミング:
userIdが変更されるたびに、「古いuserIdでのクリーンアップ」 が実行された後、「新しいuserIdでのエフェクト」 が実行される。
実行順序の例(userIdが 1 から 2 に変わる場合)
-
userId = 1でエフェクト実行 -
userIdが2に変更される - クリーンアップ実行(userId = 1 の状態を参照)
- 新しいエフェクト実行(
userId = 2の状態を参照) - アンマウント時にクリーンアップ実行(
userId = 2の状態を参照)
3. 実務でよく使う4つのクリーンアップパターン
3.1 タイマー(setInterval / setTimeout)
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
クリーンアップを忘れると、コンポーネントが画面から消えてもバックグラウンドでタイマーが動き続け、メモリを消費する。
3.2 イベントリスナー
useEffect(() => {
const handler = (e) => console.log(e.clientX);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
addEventListener と removeEventListener には、全く同じ関数のインスタンスを渡す必要がある。上記のように useEffect 内部で関数を定義していれば、参照がズレることはない。
3.3 サブスクリプション(WebSocket など)
useEffect(() => {
const channel = chatApi.subscribe('room-1', (msg) => {
setMessages(prev => [...prev, msg]);
});
return () => {
channel.unsubscribe();
};
}, []);
3.4 非同期処理(APIフェッチ)のキャンセル・防止
コンポーネントがアンマウントされた後にAPIリクエストが完了し、存在しないStateを更新しようとするエラーを防ぐ。
方法A:AbortController(リクエスト自体をキャンセルする)
useEffect(() => {
const controller = new AbortController();
fetch('/api/user', { signal: controller.signal })
.then(res => res.json())
.then(data => setData(data))
.catch(err => {
if (err.name === 'AbortError') return; // キャンセルによるエラーは無視
});
return () => controller.abort();
}, []);
方法B:Booleanフラグ(State更新のみをスキップする)
サードパーティのSDKや、fetch 以外の非同期処理では、フラグを用いた管理がシンプルで扱いやすい。
useEffect(() => {
let active = true;
const loadData = async () => {
const data = await myCustomSdk.get();
if (active) {
setData(data);
}
};
loadData();
return () => {
active = false; // クリーンアップ時にフラグを折る
};
}, []);
4. クリーンアップにまつわる注意点とアンチパターン
4.1 useEffectのコールバック自体を async にしてはいけない
useEffect の第一引数に渡す関数は、クリーンアップ関数(または何もなし)を返す必要がある。しかし、async 関数は自動的に Promise を返してしまうため、Reactがクリーンアップ関数を正しく認識できずエラーになる。
// ❌ NGな書き方
useEffect(async () => {
const data = await fetch('/api');
return () => { /* 実行されない */ };
}, []);
// ⭕ 正しい書き方
useEffect(() => {
const init = async () => {
const data = await fetch('/api');
};
init();
return () => { /* 正しく実行される */ };
}, []);
5. まとめ:そもそも useEffect は必要か?
実務における設計指針として、クリーンアップを書くべきかどうかの基準は以下の通りである。
- 書くべきケース: タイマー、イベントリスナー、WebSocketなど、Reactの外部システムを制御する場合。
- 不要なケース: React内部のState更新や、受け取ったpropsに基づく計算のみの場合。
また、近年のReact(React 19以降など)のトレンドとして、データのフェッチに useEffect を直接使うことは少なくなっている。実務では TanStack Query (React Query) や SWR などのデータフェッチライブラリ、またはフレームワーク(Next.jsなど)の機能を活用し、極力 useEffect の管理自体を減らす設計が推奨されている。