2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React useEffect におけるメモリリークの仕組み

Posted at

業務の中でメモリリークについて触れる機会があったので、まとめます。

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() は、ブラウザのメモリ上にある BlobFile オブジェクトに対して、一時的な 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 クリーンアップ関数の実行タイミング

クリーンアップ関数は以下のタイミングで実行されます:

  1. コンポーネントのアンマウント時
  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]);

問題点:

  1. setPreviewUrl は非同期で、即座に previewUrl を更新しない
  2. クリーンアップ関数が定義される時点で、previewUrl はまだ古い値(undefined)
  3. そのため、クリーンアップ関数内で previewUrl を参照しても、正しい URL を取得できない
  4. 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 の setStateuseState の 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 リソース管理の原則

  1. 作成したリソースは必ず解放する

    • URL.createObjectURLURL.revokeObjectURL
    • addEventListenerremoveEventListener
    • setIntervalclearInterval
  2. ローカル変数を使用する

    • 状態に依存せず、エフェクト内で直接管理
    • クリーンアップ関数から確実に参照できる
  3. 依存配列を正確に設定する

    • 使用するすべての値を依存配列に含める
    • 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. まとめ

メモリリークが発生する理由

  1. URL.createObjectURL で作成した URL は明示的に解放する必要がある
  2. React の状態更新は非同期のため、クリーンアップ関数内で状態を参照できない
  3. クロージャにより、クリーンアップ関数は定義時の変数を参照する
  4. 状態を参照すると、古い値や undefined を参照してしまう

解決方法

  1. ローカル変数を使用する

    • エフェクト内で直接管理
    • クリーンアップ関数から確実に参照できる
  2. 作成したリソースを直接参照する

    • 状態に依存せず、ローカル変数を参照
  3. 依存配列を正確に設定する

    • 使用する値をすべて含める

重要なポイント

  • ブラウザ API で作成したリソースは必ず解放する
  • 状態ではなく、ローカル変数を使用する
  • クリーンアップ関数は、作成したリソースを直接参照する
2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?