🗿

[React] useEffectのクリーンアップでメモリリークを防ごう!

2023/04/30に公開
1

概要

useEffectのクリーンアップについてあまり意識していなかったが、全然意識した方がいいレベルで大事だったので、軽く用途をまとめます。
https://react.dev/reference/react/useEffect

メモリリークとは

メモリリークとは、プログラムがメモリを確保し続けるが、そのメモリを解放しないことによって起こる問題である。
必要のなくなったメモリを解放しない状態が続くとメモリリークとなり、アプリケーションのパフォーマンスの低下やクラッシュにつながる可能性がある。

よくあるメモリリークの原因に以下のような事例が挙げられる。

  • イベントリスナの解放漏れ
    イベントリスナを削除しない場合、リスナーがDOM要素への参照を保持し続けるため、メモリリークが発生する。
    Reactではほとんど見ることはないが、以下のようなコードが該当する
useEffect(() => {
  const handler = () => console.log('onclick');
  window.addEventListener('onclick', handler);
}, []);
  • タイマーやインターバルの解放漏れ
    setTimeoutsetIntervalなどを削除せず、バックグラウンドで動き続けてしまうとメモリリークが発生する。
 const [count, setCount] = useState(0);

 useEffect(() => {
    setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
  }, []);
  • URL.createObjectURL()の解放漏れ
    blobFileを表すURLを生成するcreateObjectURLは、すでにオブジェクト URL が生成されている場合でも、 createObjectURL() を呼び出す度に、新しいオブジェクト URL が生成される。 必要がなくなったら URL.revokeObjectURL() を呼び出して、メモリを解放しないとメモリリークにつながる可能性がある。
 const [file, setFile] = useState(FileObject);
 const [url, setUrl] = useState('url');
 
 useEffect(() => {
    const createdUrl = URL.createObjectURL(file);
    setUrl(createdUrl);
  }, [file]);
  • 外部システムからのデータの取得中にアンマウントされた際
    例えば、外部システム(APIサーバーなど)からデータを取得している途中で違うページに遷移され、コンポーネントがアンマウントされた後に、取得したデータを保存するメモリを割り当てようとするとメモリリークとなる。
const [data, setData] = useState()
useEffect(() => {
    const startFetching = async() => {
      const result = await fetchData();
      setData(result)
    }
    startFetching();
  }, []);

useEffectのクリーンアップの流れ

useEffect(setup, dependencies?)
useEffect(セットアップコード, 依存関係リスト)
公式に記載されている流れは以下である。

  1. コンポーネントがマウントされると、セットアップコードが呼び出される。
  2. 依存関係が変更され、レンダリングするたびに
     1. クリーンアップコードが古いpropsとstateで実行される。
     2. セットアップコードが新しいpropsとstateで実行される。
  3. クリーンアップコードはコンポーネントがアンマウントされる際に、最後に一回実行される。

クリーンアップをしてメモリリークを防ぐ例

  • イベントリスナの解放漏れ
    イベントリスナの解除はwindow.removeEventListenerにて行う。
useEffect(() => {
  const handler = () => console.log('onclick');
  window.addEventListener('onclick', handler);
  
  return () => window.removeEventListener('onclick', handler);
}, []);
  • タイマーやインターバルの解放漏れ
    タイマーの解除をclearInterval()で行う。
const [count, setCount] = useState(0);

 useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  • URL.createObjectURL()の解放漏れ
    revokeObjectURL()でメモリを解放する。
 const [file, setFile] = useState(FileObject);
 const [url, setUrl] = useState('url');
 
 useEffect(() => {
    const createdUrl = URL.createObjectURL(file);
    setUrl(createdUrl);
    return () => URL.revokeObjectURL(url)
  }, [file]);

外部システムからのデータの取得中にアンマウントされた際
一番シンプルな方法はマウントされているかどうかのフラグを持つことでメモリリークを防ぐことができる。

const [data, setData] = useState()
useEffect(() => {
    let mount = true; //マウントされているか
    const startFetching = async() => {
      const result = await fetchData();
      if (mount) setData(result) //マウントされている場合
    }
    startFetching();
    return () => mount = false //クリーンアップでマウントフラグをfalseに
  }, []);

公式にもあるがそもそもuseEffectでAPIサーバーなどからデータを取得するのは以下の観点から、あまりおすすめできない。

エフェクトで直接フェッチするということは、通常、データをプリロードまたはキャッシュしないことを意味します。たとえば、コンポーネントがアンマウントされてから再度マウントされた場合、データを再度フェッチする必要があります。

参考
公式
useEffectのCleanupの使い方まとめ
【React】useEffectの基本的な使い方・活用術・注意点

Discussion