何が起きているか
ブラウザのコンソールを開くと、あちこちにこの警告が表示されます:
Warning: Can't perform a React state update on an unmounted component.
This is a no-op, but it indicates a memory leak in your application.
To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
典型的な原因を想像してみましょう:コンポーネントが fetch() 呼び出しを開始し、2〜3秒かかる間にユーザーが途中で戻るボタンをクリックします。それでもフェッチは完了します。コールバックはすでに存在しないコンポーネントに対して setUser(data) を呼び出します。Reactはそれを検知して更新をスキップします。クラッシュはしません。しかし、リクエストは完了し、コールバックは実行され、メモリは無駄に保持されています。これが十分な数のコンポーネントで起きると、本物のメモリリークになります。
問題を再現する
最小限のトリガーはこちらです:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data)); // ← アンマウント後に発火する
}, [userId]);
return <div>{user?.name}</div>;
}
フェッチが完了する前に UserProfile をアンマウントすると — 戻るボタン、ルート変更、条件付きレンダリングなど — setUser(data) が無効なコンポーネントに対して実行されます。
修正1: AbortControllerでフェッチをキャンセルする(推奨)
AbortController はコールバックだけでなく、実行中のネットワークリクエスト自体を中断します。ブラウザはリクエストを途中で停止し、AbortError をスローします。これをキャッチして無視します:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name === 'AbortError') return; // キャンセルを無視する
console.error(err);
});
return () => controller.abort(); // アンマウント時にクリーンアップ
}, [userId]);
return <div>{user?.name}</div>;
}
クリーンアップ関数は、コンポーネントのアンマウント時、または前のリクエストが完了する前に userId が変更されたときに実行されます。controller.abort() がフェッチを中断します。AbortError は静かに無視されます。状態更新なし、警告なし、帯域幅の無駄なし。
修正2: isMountedフラグ(非フェッチの非同期処理向け)
キャンセルできない非同期処理もあります — サードパーティSDKのコールバック、複数の操作をラップする Promise.all()、デバウンスされた関数などです。そのような場合、ブーリアンフラグで状態更新をガードします:
function DataWidget() {
const [data, setData] = useState(null);
useEffect(() => {
let isMounted = true;
someAsyncOperation().then(result => {
if (isMounted) setData(result); // マウントされている場合のみ更新
});
return () => {
isMounted = false; // クリーンアップ: 古いコールバックを無効化
};
}, []);
return <div>{data}</div>;
}
非同期処理は最後まで実行されますが、状態には触れません。真の修正ではなく、安全ガードとして考えてください。処理は実行されますが、その結果を無視しているだけです。
修正3: タイマーとインターバル
タイマーはフェッチの次によくある問題です。アンマウント後に残った setInterval は無限に発火し続けます。常にIDを保存してクリアしてください:
function LiveClock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const interval = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(interval); // クリーンアップ
}, []);
return <span>{time.toLocaleTimeString()}</span>;
}
修正4: イベントリスナーとサブスクリプション
エフェクトで登録し、クリーンアップで登録解除する — 例外なし:
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
RxJSのサブスクリプションも同じルールに従います:
useEffect(() => {
const subscription = dataStream$.subscribe(val => setData(val));
return () => subscription.unsubscribe();
}, []);
修正5: React Query / SWR(長期的な解決策)
すべてのデータフェッチコンポーネントに AbortController のボイラープレートを書くのはすぐに面倒になります。React Queryはキャンセル、キャッシュ、重複排除、クリーンアップを自動的に処理し、この問題全体が消えます:
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json())
});
return <div>{user?.name}</div>;
}
手動クリーンアップなし。フラグなし。コードベースに useEffect 内の fetch パターンが多数ある場合、React Queryへの移行はすぐに効果を発揮します。
修正が機能したか確認する
- DevToolsを開き → コンソールを確認。
- 警告が表示されたコンポーネントに移動する。
- 非同期処理が完了する前にすぐに別のページへ移動する。
- コンソールを確認 — 警告が消えているはず。
より明確なシグナルが欲しい場合は、開発中にクリーンアップ内にログを出力してみましょう:
return () => {
console.log('cleanup ran — aborting fetch');
controller.abort();
};
フェッチのレスポンスより先に「cleanup ran」が表示されれば、クリーンアップが正しく設定されています。
React 18に関する注意
React 18はプロダクションビルドからこの警告を削除しました。アンマウントされたコンポーネントに対して setState を呼び出しても、静かに無視されるようになりました — Reactはそれについて通知しません。警告が消えたからといってバグが消えたと勘違いしないでください。非同期処理は引き続き実行され、メモリは保持され続け、古いコールバックは予期しない動作を引き起こす可能性があります。どのバージョンのReactを使用していても、エフェクトのクリーンアップを行ってください。
まとめ
- エフェクトの本体を書く前にクリーンアップ関数を書く。そうすることで、処理を開始するコードを書く前に、何をキャンセルする必要があるかを考えることができます。
AbortControllerはネットワークリクエスト自体をキャンセルし、ブラウザが転送を停止します。isMountedフラグは結果を無視するだけで、リクエストは完了します。- タイマーはクリアしないと永遠に発火し続けます。モーダル内の
setIntervalを一つ忘れるだけで、長いセッション中に測定可能なCPUを消費します。 - すべてのコンポーネントにクリーンアップのボイラープレートを書いているなら、それはReact QueryやSWRを使ってライブラリにデータのライフサイクルを任せるサインです。

