業務の中でメモリリークについて触れる機会があったので、まとめます。
React の useEffect フックを使用する際、特にブラウザ API(URL.createObjectURL など)を扱う場合、メモリリークが発生する可能性があります。
問題のコード
まず、問題のあるコードを見てみましょう:
import { useEffect, useState } from 'react';
type Props = {
file: File | null;
};
export const FilePreview = ({ file }: Props) => {
const [previewUrl, setPreviewUrl] = useState<string | undefined>(undefined);
useEffect(() => {
setPreviewUrl(file ? URL.createObjectURL(file) : undefined);
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [file]);
if (!previewUrl) return <div>ファイルが選択されていません</div>;
return <img src={previewUrl} alt="プレビュー" />;
};
このコードには、メモリリークが発生する可能性があります。なぜでしょうか?
1. メモリリークとは
1.1 メモリリークの定義
メモリリークとは、プログラムが使用しなくなったメモリを適切に解放せず、メモリが消費され続ける現象です。ブラウザ環境では、メモリリークが発生すると:
- ブラウザのメモリ使用量が増加し続ける
- パフォーマンスが低下する
- 最終的にブラウザがクラッシュする可能性がある
1.2 JavaScript のメモリ管理
JavaScript は**ガベージコレクション(GC)**という自動メモリ管理機能を持っています。しかし、以下のような場合、GC がメモリを解放できません:
- オブジェクトへの参照が残っている
- イベントリスナーが登録されたまま
- タイマーがクリアされていない
- ブラウザ API で作成したリソースが解放されていない
2. URL.createObjectURL と URL.revokeObjectURL の仕組み
2.1 URL.createObjectURL とは
URL.createObjectURL() は、ブラウザのメモリ上にある Blob や File オブジェクトに対して、一時的な URL を作成する API です。
const file = new File(['content'], 'example.txt');
const url = URL.createObjectURL(file);
// url は "blob:http://localhost:3000/abc123-def456-..." のような形式
重要なポイント:
- この URL はブラウザのメモリ上に保存される
- メモリを消費する
- 明示的に解放する必要がある
2.2 URL.revokeObjectURL とは
URL.revokeObjectURL() は、createObjectURL で作成した URL を無効化し、メモリを解放する API です。
URL.revokeObjectURL(url);
// これ以降、この URL は使用できなくなる
// メモリが解放される
重要なポイント:
- 作成した URL ごとに必ず呼び出す必要がある
- 呼び出さないと、メモリが解放されない(メモリリーク)
- 一度 revoke した URL は使用できない
2.3 メモリリークの例
// 悪い例:メモリリークが発生する
function BadExample() {
const file = new File(['content'], 'example.txt');
const url = URL.createObjectURL(file);
// revoke を呼ばない → メモリリーク
}
// 良い例:適切にメモリを解放
function GoodExample() {
const file = new File(['content'], 'example.txt');
const url = URL.createObjectURL(file);
// 使用後、必ず revoke
URL.revokeObjectURL(url);
}
3. React の useEffect とクリーンアップ関数
3.1 useEffect の基本構造
useEffect は、コンポーネントの副作用(side effects)を管理するフックです。
useEffect(() => {
// 副作用の処理(マウント時、依存配列の値が変更された時に実行)
return () => {
// クリーンアップ関数(アンマウント時、依存配列の値が変更される前に実行)
};
}, [依存配列]);
3.2 クリーンアップ関数の実行タイミング
クリーンアップ関数は以下のタイミングで実行されます:
- コンポーネントのアンマウント時
- 依存配列の値が変更される前(新しいエフェクトが実行される前)
useEffect(() => {
console.log('エフェクト実行');
return () => {
console.log('クリーンアップ実行');
};
}, [dependency]);
// dependency が 'A' → 'B' に変更された場合:
// 1. クリーンアップ実行('A' の時のクリーンアップ)
// 2. エフェクト実行('B' の時のエフェクト)
3.3 クロージャとスコープの概念
JavaScript のクロージャは、関数が定義された時のスコープ(変数の参照)を保持する仕組みです。
function outer() {
let count = 0;
function inner() {
console.log(count); // 外側の count を参照できる(クロージャ)
count++;
}
return inner;
}
const fn = outer();
fn(); // 0
fn(); // 1
fn(); // 2
重要なポイント:
- 関数は定義時の変数を「閉じ込める」(クロージャ)
-
useEffectのクリーンアップ関数も、定義時の変数を参照する
4. 問題のコードでメモリリークが発生する理由
4.1 コードの実行フロー
問題のコードを詳しく見てみましょう:
useEffect(() => {
// ステップ1: 新しい URL を作成
setPreviewUrl(file ? URL.createObjectURL(file) : undefined);
// ステップ2: クリーンアップ関数を返す
return () => {
// ステップ3: previewUrl を参照
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [file]);
4.2 メモリリークが発生するシナリオ
file が変更された場合の実行フロー:
初回レンダリング(file = file1)
1. useEffect 実行
- URL.createObjectURL(file1) → url1 を作成
- setPreviewUrl(url1)
- クリーンアップ関数を返す(この時点で previewUrl は undefined)
2. 状態更新(非同期)
- previewUrl が url1 に更新される
2回目のレンダリング(file = file2)
1. クリーンアップ関数実行(file1 の時のクリーンアップ)
- previewUrl を参照
- しかし、この時点で previewUrl はまだ url1(古い値)
- でも、クロージャの仕組みにより、クリーンアップ関数が定義された時点の
previewUrl を参照する可能性がある
実際には:
- クリーンアップ関数が定義された時点で previewUrl は undefined
- そのため、URL.revokeObjectURL が呼ばれない
- url1 が解放されない → メモリリーク!
2. useEffect 実行(新しいエフェクト)
- URL.createObjectURL(file2) → url2 を作成
- setPreviewUrl(url2)
- 新しいクリーンアップ関数を返す
4.3 クロージャによる問題の詳細
クリーンアップ関数は、定義された時点の previewUrl の値を「閉じ込める」(クロージャ)ため、以下の問題が発生します:
// 初回実行時
useEffect(() => {
const newUrl = URL.createObjectURL(file1); // url1 を作成
setPreviewUrl(newUrl); // 状態を更新(非同期)
// クリーンアップ関数が定義される時点で、
// previewUrl はまだ undefined(古い値)
return () => {
// この時点で参照できる previewUrl は undefined
// なぜなら、setPreviewUrl は非同期で、まだ反映されていない
if (previewUrl) { // undefined なので false
URL.revokeObjectURL(previewUrl); // 実行されない
}
};
}, [file]);
問題点:
-
setPreviewUrlは非同期で、即座にpreviewUrlを更新しない - クリーンアップ関数が定義される時点で、
previewUrlはまだ古い値(undefined) - そのため、クリーンアップ関数内で
previewUrlを参照しても、正しい URL を取得できない -
URL.revokeObjectURLが呼ばれず、メモリが解放されない
4.4 タイミングの問題
さらに、speaker.profileImage が頻繁に変更される場合:
時刻 T1: file1 が設定される
- url1 を作成
- クリーンアップ関数を返す(previewUrl = undefined を参照)
時刻 T2: file2 が設定される(file1 が変更される)
- クリーンアップ関数実行(previewUrl = undefined を参照)
→ URL.revokeObjectURL が呼ばれない
→ url1 が解放されない(メモリリーク!)
- url2 を作成
- 新しいクリーンアップ関数を返す
時刻 T3: file3 が設定される
- クリーンアップ関数実行(previewUrl = url2 を参照)
→ URL.revokeObjectURL(url2) が呼ばれる
→ url2 は解放される
- url3 を作成
- 新しいクリーンアップ関数を返す
問題:url1 は永遠に解放されない!
5. 改善されたコード
5.1 正しい実装
useEffect(() => {
// 新しいオブジェクトURLを作成
const newUrl = file
? URL.createObjectURL(file)
: undefined;
setPreviewUrl(newUrl);
// クリーンアップ関数: 作成したURLを確実に解放
return () => {
if (newUrl) {
URL.revokeObjectURL(newUrl);
}
};
}, [file]);
5.2 改善点の詳細
ポイント1: ローカル変数を使用
const newUrl = file
? URL.createObjectURL(file)
: undefined;
-
newUrlはローカル変数として、useEffect内で直接管理 - 状態(
previewUrl)に依存しない - クリーンアップ関数から確実に参照できる
ポイント2: クリーンアップ関数で直接参照
return () => {
if (newUrl) {
URL.revokeObjectURL(newUrl);
}
};
- クリーンアップ関数内で
newUrl(ローカル変数)を直接参照 - クロージャにより、作成した URL を確実に保持
- 依存配列の値が変更される前に、必ず解放される
5.3 実行フローの比較
改善前(メモリリークが発生)
初回: file1
- url1 を作成
- setPreviewUrl(url1)(非同期)
- クリーンアップ関数を返す(previewUrl = undefined を参照)
2回目: file2
- クリーンアップ実行(previewUrl = undefined)
→ URL.revokeObjectURL が呼ばれない
→ url1 が解放されない(メモリリーク!)
- url2 を作成
- setPreviewUrl(url2)
- 新しいクリーンアップ関数を返す
改善後(メモリリークが解消)
初回: file1
- newUrl = url1 を作成(ローカル変数)
- setPreviewUrl(url1)
- クリーンアップ関数を返す(newUrl = url1 を参照)
2回目: file2
- クリーンアップ実行(newUrl = url1 を参照)
→ URL.revokeObjectURL(url1) が呼ばれる
→ url1 が解放される ✓
- newUrl = url2 を作成(新しいローカル変数)
- setPreviewUrl(url2)
- 新しいクリーンアップ関数を返す(newUrl = url2 を参照)
6. さらなる理解:React の状態更新の非同期性
6.1 状態更新は非同期
React の setState(useState の setter)は非同期です:
const [count, setCount] = useState(0);
console.log(count); // 0
setCount(1);
console.log(count); // まだ 0(非同期のため)
// 次のレンダリングで count は 1 になる
6.2 クリーンアップ関数の実行タイミング
useEffect(() => {
const newValue = calculateValue();
setState(newValue); // 非同期で更新される
return () => {
// この時点で state はまだ古い値
// そのため、state を参照するのは危険
};
}, [dependency]);
重要なポイント:
- クリーンアップ関数は、エフェクトが実行された直後に定義される
- その時点では、
setStateによる更新はまだ反映されていない - そのため、状態を参照するのではなく、ローカル変数を使用する必要がある
7. ベストプラクティス
7.1 リソース管理の原則
-
作成したリソースは必ず解放する
-
URL.createObjectURL→URL.revokeObjectURL -
addEventListener→removeEventListener -
setInterval→clearInterval
-
-
ローカル変数を使用する
- 状態に依存せず、エフェクト内で直接管理
- クリーンアップ関数から確実に参照できる
-
依存配列を正確に設定する
- 使用するすべての値を依存配列に含める
- ESLint の警告に従う
7.2 パターン集
パターン1: オブジェクトURL
useEffect(() => {
const url = file ? URL.createObjectURL(file) : undefined;
setImageUrl(url);
return () => {
if (url) URL.revokeObjectURL(url);
};
}, [file]);
パターン2: イベントリスナー
useEffect(() => {
const handleClick = () => {
// 処理
};
window.addEventListener('click', handleClick);
return () => {
window.removeEventListener('click', handleClick);
};
}, []);
パターン3: タイマー
useEffect(() => {
const timerId = setInterval(() => {
// 処理
}, 1000);
return () => {
clearInterval(timerId);
};
}, []);
8. まとめ
メモリリークが発生する理由
URL.createObjectURLで作成した URL は明示的に解放する必要がある- React の状態更新は非同期のため、クリーンアップ関数内で状態を参照できない
- クロージャにより、クリーンアップ関数は定義時の変数を参照する
- 状態を参照すると、古い値や undefined を参照してしまう
解決方法
-
ローカル変数を使用する
- エフェクト内で直接管理
- クリーンアップ関数から確実に参照できる
-
作成したリソースを直接参照する
- 状態に依存せず、ローカル変数を参照
-
依存配列を正確に設定する
- 使用する値をすべて含める
重要なポイント
- ブラウザ API で作成したリソースは必ず解放する
- 状態ではなく、ローカル変数を使用する
- クリーンアップ関数は、作成したリソースを直接参照する