コード例には React 18 以降が必要です。 StrictMode の二重呼び出し動作は React 18 で導入されました。
React useEffect のクリーンアップ関数の欠落は、メモリ リーク、古い状態のバグ、競合状態の最も一般的な原因の 1 つです。一般的な初心者向けチュートリアルでは、データのフェッチ、状態の設定、レンダリングという幸せな道筋を示しています。コンポーネントが飛行中にアンマウントされた場合、非同期操作の解決よりも早く依存関係が変更された場合、または再レンダリング中にイベント リスナーが静かに蓄積された場合に何が起こるかについてはほとんど言及されていません。この記事では、3 つのカテゴリ (タイマー、リスナー、フェッチ レース) にわたる useEffect クリーンアップの統一メンタル モデル、落とし穴までクリーンアップするための参照テーブル マッピング セットアップ、および開発チームがすぐに採用できるコピー&ペーストの PR レビュー チェックリストを提供します。
目次
useEffect クリーンアップの実際の仕組み
セットアップとクリーンアップのライフサイクルを一文で表す
メンタル モデルは単純です。React は、コンポーネントがマウントされるときにセットアップ関数を実行し、コンポーネントがアンマウントされるときにクリーンアップ関数を実行します。また、依存関係が変更されるたびに、現在のレンダーのエフェクトのセットアップを実行する前に、前のレンダーのエフェクトのクリーンアップを実行します。依存関係の変更に関するこのクリーンアップ、その後のセットアップのサイクルは、ほとんどの開発者が見落としているか誤解している部分です。これは、すべてのエフェクトの呼び出しが対応するティアダウンとペアになっており、React がその順序を保証することを意味します。 setup 関数はリソースを取得します。クリーンアップリターンによりそれらは解放されます。依存関係配列は、変更されたときにどの値がそのサイクルを再度トリガーするかを宣言します。
StrictMode がエフェクトを二重に呼び出す理由
React 18 の StrictMode は各コンポーネントのエフェクトを意図的に実行します 二度 開発中は、セットアップ→クリーンアップ→セットアップというアンマウントと再マウントをシミュレートします。セットアップ関数が実行されます 二度 開発中の初期レンダリング時。この二重呼び出しは、欠落しているクリーンアップ ロジックを明らかにするために特に存在します。コンポーネントがセットアップ時にイベント リスナーを登録し、クリーンアップ時にイベント リスナーを削除しない場合、StrictMode はシミュレートされた再マウント後に 2 つのリスナーを生成するため、開発中にバグがすぐに確認できるようになります。 StrictMode の React ドキュメントに記載されているように (この動作は開発モードでのみ発生し、本番環境のビルドには影響しません。ただし、それが公開するバグは完全に現実のものです。StrictMode のダブルマウントで誤って動作するコンポーネントには、ナビゲーション、条件付きレンダリング、または親の再レンダリングによってコンポーネントが再マウントされるたびに本番環境で現れる潜在的なクリーンアップ バグがあります。
StrictMode のダブルマウントで正しく動作しないコンポーネントには潜在的なクリーンアップ バグがあり、ナビゲーション、条件付きレンダリング、または親の再レンダリングによってコンポーネントが再マウントされるたびに本番環境で顕在化します。
import { useEffect } from 'react';
function ExampleComponent({ dependency }) {
useEffect(() => {
console.log('Effect setup with dependency:', dependency);
return () => {
console.log('Effect cleanup for dependency:', dependency);
};
}, [dependency]);
return <div>Anatomy of useEffectdiv>;
}
パターン 1: タイマーのクリーンアップ (setInterval / setTimeout)
バグ: 再レンダリング時のスタッキング間隔
とき setInterval prop または state 値に依存する useEffect 内で開始され、その依存関係が変更されるたびに、React はエフェクトを再実行します。クリーンアップを行わないと、以前の間隔が引き続き実行されます。 2 番目のインターバルが始まります。それから3番目。観察可能な症状は、間隔の動作によって異なります。カウンタがティックごとに N 回増加する (N は孤立した間隔の数)、ポーリング コールバックがティックごとに複数回起動する、または UI アニメーションの速度が異常に速くなります。孤立した各間隔は、古い状態に対するクロージャを保持しており、問題を複雑にしています。開発中の StrictMode のダブルマウントでは、最初のレンダリングから 2 つのインターバルが実行されると、このバグがすぐに表面化します。
修正: クリーンアップリターンのclearInterval
正しいパターンでは、セットアップ時に間隔が作成され、クリーンアップ時に間隔がクリアされます。依存関係が変更されると、React は古い間隔をクリアしてから、更新された値で新しい間隔を開始します。これにより、どの時点でもアクティブな間隔が 1 つだけ保証されます。
import { useEffect, useState } from 'react';
function PollingComponent({ delay }) {
const [count, setCount] = useState(0);
useEffect(() => {
if (!delay || delay <= 0 || !Number.isFinite(delay)) return;
const id = setInterval(() => {
setCount(c => c + 1);
}, delay);
return () => {
clearInterval(id);
};
}, [delay]);
return <div>Count: {count}div>;
}
同じ原則が次の場合にも当てはまります setTimeout。交換する setInterval と setTimeout そして clearInterval と clearTimeout。スケジューリング関数によって返されたタイマー ID を保存し、クリーンアップの戻り値でクリアします。
パターン 2: イベント リスナーのクリーンアップ
バグ: ウィンドウ/ドキュメント上のリスナーのパイルアップ
イベントリスナーを追加する window、 documentまたは、コンポーネント自体のレンダー ツリーの外側にある DOM ノードは手動でクリーンアップする必要があります。マッチングなしで removeEventListener クリーンアップの戻りでは、再レンダリング (または StrictMode ダブルマウント) ごとに追加のハンドラーがアタッチされます。ハンドラーは蓄積されます。状態を更新するサイズ変更リスナーは、孤立したリスナーごとに 1 回ずつ、サイズ変更イベントごとに複数回 setState をトリガーします。これによりパフォーマンスが低下し、複数のハンドラーが同じ状態変数を更新するために競合するときに微妙な状態の破損が発生する可能性があります。
修正: 同じ参照で追加と削除を一致させる
重要な詳細は、 removeEventListener に渡されたものとまったく同じ関数参照を受け取る必要があります addEventListener。エフェクト本体内で作成されたインライン アロー関数は、呼び出しごとに新しい参照を生成するため、 removeEventListener 別のインライン矢印を使用しても何も起こりません。リスナーが削除されることはありません。ハンドラーをエフェクト内で名前付き関数 (または関数を保持する変数) として宣言し、その同じ変数を両方に渡します。 addEventListener そして removeEventListener。
重要な詳細は、
removeEventListenerに渡されたものとまったく同じ関数参照を受け取る必要がありますaddEventListener。
import { useEffect, useState } from 'react';
function WindowSizeTracker() {
const [width, setWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 0);
useEffect(() => {
if (typeof window === 'undefined') return;
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return <div>Window width: {width}pxdiv>;
}
ハンドラーが prop または state 値に依存し、エフェクトの外部で定義されている場合 (たとえば、 useCallback)、そのハンドラーは依存関係配列に存在する必要があります。参照が変更されると、React は古いリスナーをクリーンアップして、新しいリスナーをアタッチします。依存関係が変更されるたびにリスナーを再追加するのは正しい動作であることに注意してください。古いハンドラー (古いクロージャを持つ) が削除され、新しいハンドラー (新しい値を持つ) がアタッチされます。
パターン 3: AbortController によるフェッチ競合状態のキャンセル
バグ: 古い応答が最新の状態を上書きする
ユーザーが素早く入力する検索コンポーネントを考えてみましょう。キーストロークごとに新しいフェッチがトリガーされます。 「rea」のリクエストには 300 ミリ秒かかる場合がありますが、「react」のリクエストには 50 ミリ秒かかります。 「反応」応答が最初に到着し、UI には正しい結果が表示されます。次に、「rea」応答が到着しますが、setState の呼び出しを妨げるものがないため、UI が古くて正しくない結果で上書きされます。これは典型的な競合状態です。遅い応答が優先されるのは、応答が新しいからではなく、後で解決されるからです。キャンセルしないと、結果が関係なくなった実行中のリクエストを破棄するようにブラウザに指示するメカニズムがありません。
修正: クリーンアップ時の AbortController シグナル
現代的な解決策は、 AbortController。エフェクトを呼び出すたびに、新しいコントローラー インスタンスが作成されます。コントローラーの signal プロパティが渡される fetch オプションとして。クリーンアップ関数が実行されると (依存関係が変更されたか、コンポーネントがアンマウントされたため)、 controller.abort()これにより、実行中のリクエストがキャンセルされます。 fetch Promise は次のエラーで拒否されます。 AbortError。ブラウザは通常、基礎となるネットワーク リクエストもキャンセルしようとしますが、その動作はエンジンによって異なります (AbortSignal の Fetch 仕様と MDN のブラウザ互換性テーブルを参照してください)。これにより、最新のリクエストのみが正常に状態を設定できることが保証されます。
AbortError を適切に処理する
電話をかける abort() 約束を拒否します。これは正常な状態であり、エラー状態ではありません。 catch ブロックは区別する必要があります AbortError 本物のネットワーク障害から。このガードがないと、UI は意図的なキャンセルごとにエラーをフラッシュするか、拒否によってエラー ログ インフラストラクチャがトリガーされます。標準チェックではエラーを検査します。 name プロパティ: 等しい場合 'AbortError'catch ブロックは、状態を更新したりエラーを検出したりすることなく、早期に戻ります。
import { useEffect, useState } from 'react';
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!query) {
setResults([]);
setError(null);
setLoading(false);
return;
}
const controller = new AbortController();
async function fetchResults() {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const items = Array.isArray(data?.items) ? data.items : [];
setResults(items);
} catch (err) {
if (err.name === 'AbortError') {
return;
}
setError(err.message);
} finally {
setLoading(false);
}
}
fetchResults();
return () => {
controller.abort();
};
}, [query]);
if (error) return <div>Error: {error}div>;
if (loading) return <div>Loading...div>;
return (
<ul>
{results.map((item, index) => (
<li key={item.id ?? `item-${index}`}>{item.title ?? '(Untitled)'}li>
))}
ul>
);
}
エフェクト関数自体は非同期ではないことに注意してください。内部の非同期関数が宣言され、すぐに呼び出されます。 useEffect は戻り値が未定義またはクリーンアップ関数であることを期待しているため、これは重要です。 React は返された Promise を無視し、そこからクリーンアップ関数を登録しません。
参照表: セットアップ、クリーンアップ、および落とし穴の概要
次の表は、React アプリケーションで最も一般的な副作用にわたる普遍的なパターンを示しています。各行は同じ構造を示しています。つまり、セットアップでリソースを取得し、クリーンアップでリソースを解放し、クリーンアップが欠落している場合の特定の障害モードを理解します。
| シナリオ | セットアップアクション | クリーンアップアクション | よくある落とし穴 |
|---|---|---|---|
setInterval / setTimeout | タイマーを開始します | clearInterval / clearTimeout | 依存関係変更時のスタック間隔 |
addEventListener | アタッチハンドラー | removeEventListener 同じ参照を持つ | インライン矢印≠同じ参照 |
fetch | 火災要請 signal | controller.abort() | 古い応答により状態が上書きされる |
| ウェブソケット | new WebSocket(url) + ハンドラーをアタッチする | Null ハンドラーの場合 ws.close() | アンマウント時の接続リーク |
サードパーティのサブスクリプション (Firebase など) onSnapshot) | 購読する | 通話解除機能 | 戻り値を忘れるとunsubになります |
WebSocket と Firebase のパターンは参考のためにここに記載されています。完全な実装の詳細については、MDN の WebSocket API ドキュメントと Firebase ドキュメントを参照してください。
パターンは常に同じです。リソースを取得し、リソースを解放し、依存関係を正直に宣言します。セットアップで何かが作成された場合、クリーンアップによってそれが破棄されます。セットアップが接続すると、クリーンアップは切断されます。この対称性を覚えておくと、新しい副作用はすべて同じ原理を直接適用することになります。
useEffect クリーンアップのための PR レビュー チェックリスト
このチェックリストは、チームの貢献ガイドラインに直接組み込んだり、コード レビュー中の頭の中でのウォークスルーとして使用したりできるように設計されています。すべての質問は、特定のカテゴリのクリーンアップ バグを対象としています。
- すべてを実行します
useEffectクリーンアップ関数を返す副作用が発生しますか? - クリーンアップによってセットアップが完全に元に戻されますか (タイマーのクリア、リスナーの削除、フェッチの中止)。
- イベント ハンドラー参照は安定していますか (名前付き関数または
useCallback) それでremoveEventListener実際に一致しますか? - は
AbortError本物のネットワークエラーとは別に捕捉され、処理されますか (例: エラー状態を設定したりログを記録せずに早期に復帰したり)? - 依存関係の配列には、エフェクト内で参照されるすべての値が含まれていますか?
- コンポーネントは React StrictMode ダブルマウントでテストされましたか?
- 何かありますか
eslint-disable react-hooks/exhaustive-depsコメントは?そうである場合、抑制が安全である理由を説明する付随のコード コメントによってそれぞれが正当化されますか?
7 つのチェックすべてに合格した PR は、運用環境に到達する前に、最も頻繁に報告されるクリーンアップ関連のバグ カテゴリをカバーします。
Exhaustive-Deps Lint ルールの抑制
の react-hooks/exhaustive-deps ESLint ルールは、古いクロージャの原因となる欠落した依存関係を検出するために存在します。ルールを無効にすると、バグは修正されるのではなく非表示になります。エフェクトは古い値を超えると終了し、その値の変更に応じて再実行されることはありません。そのため、再現とデバッグが困難な古いクロージャが生成されます。ルールが依存関係にフラグを立てたら、依存関係を追加します。それによって望ましくない再実行が発生する場合は、エフェクトを再構築します。ロジックを抽出するか、複数のエフェクトに分割するか、リファレンスを安定させます。 useCallback または useMemo。抑制は最後の手段であるべきであり、常に省略が意図的で安全である理由を説明するコメントと組み合わせてください。
関数の代わりに Promise を返す
エフェクトのコールバックを次のようにマークする async よくある罠です。アン async 関数は暗黙的に Promise を返します。 React は戻り値が次のいずれかを期待します。 undefined または同期クリーンアップ関数。 React は返された Promise を無視し、クリーンアップ関数を登録しないため、アンマウントまたは依存関係の変更では何も実行されません。修正方法は常に同じです。エフェクト内に内部非同期関数を定義してすぐに呼び出し、return ステートメントが適切なクリーンアップ関数を提供できるように、外部エフェクト関数の同期を維持します。
React は返された Promise を無視し、クリーンアップ関数を登録しないため、アンマウントまたは依存関係の変更では何も実行されません。
重要なポイント
- 毎
useEffectリソースを取得した場合は、それを解放するクリーンアップ関数を返さなければなりません。唯一の例外: 永続的な副作用を生じない効果 (例: 1 回限りのファイア アンド フォーゲット分析呼び出し)。 AbortControllerは、フェッチリクエストをキャンセルするための最新のブラウザネイティブツールです。ブール フラグやシーケンス カウンターを使用せずに競合状態を排除します。- リソースを取得し、リソースを解放し、依存関係を正直に宣言します。この原則は、タイマー、リスナー、フェッチ、WebSocket、およびサードパーティのサブスクリプションにも同様に適用されます。
- 7 項目の PR レビュー チェックリストを投稿ガイドラインまたはプル リクエスト テンプレートにドロップします。最も一般的に報告されているクリーンアップのバグを、本番環境に到達する前に検出します。
Source link