TL;DR

  • JavaScriptのGCは 参照されなくなったオブジェクト のみ解放する
  • 副作用で外部リソースを使う場合は 必ずクリーンアップ関数を実装 する
  • イベントリスナーは登録と解除で 同一の関数参照 を使う
  • 非同期処理は AbortController でキャンセルする
  • タイマー(setInterval、requestAnimationFrame)は 必ず停止処理を書く

はじめに

JavaScriptにはガベージコレクション(GC)があり、参照されなくなったオブジェクトを自動的に解放してくれます。 Reactのコンポーネント内で定義されたローカル変数や状態は、コンポーネントのアンマウントとともに参照が切れ、基本的にはGCの対象になります。

一方で、副作用として登録した外部リソース(イベントリスナー、タイマー、サブスクリプション、非同期処理など)は、コンポーネントがアンマウントしても自動では解除されません。**クリーンアップを忘れると参照や処理が残り続け、メモリ使用量の増加やパフォーマンス劣化(いわゆるメモリリーク)につながる可能性があります。**そのため、これらは クリーンアップ関数で明示的に解除する必要があります

この問題はReact特有のものではなく、他のフロントエンドフレームワークや素のJavaScriptでも同様に発生します。本記事ではReactを例に、意図的に解除・クリーンアップが必要なパターンについて取り上げていきます。


useEffectは「ReactとJavaScript世界の境界」

useEffectは、ReactのライフサイクルとJavaScriptの副作用(イベント、通信、タイマーなど)をつなぐための仕組みです。

ここで登録した処理は、必ず解除(クリーンアップ)まで含めて設計しないと、参照が残り続けます。


イベントリスナーの解除漏れ

Bad: クリーンアップがない

import { useEffect } from "react";

export const BadWindowListener = () => {
  useEffect(() => {
    const onResize = () => {
      console.log("resize");
    };
    window.addEventListener("resize", onResize);
  }, []);

  return null;
};

Good: 必ず解除する

import { useEffect } from "react";

export const GoodWindowListener = () => {
  useEffect(() => {
    const onResize = () => {
      console.log("resize");
    };

    window.addEventListener("resize", onResize);

    return () => {
      window.removeEventListener("resize", onResize);
    };
  }, []);

  return null;
};

関数参照が違って解除できないパターン

Bad: 無名関数を登録している

import { useEffect } from "react";

export const BadListenerReference = () => {
  useEffect(() => {
    window.addEventListener("scroll", () => {
      console.log("scroll");
    });

    return () => {
      window.removeEventListener("scroll", () => {
        console.log("scroll");
      });
    };
  }, []);

  return null;
};

Good: 同一参照の関数を使う

import { useEffect } from "react";

export const GoodListenerReference = () => {
  useEffect(() => {
    const onScroll = () => {
      console.log("scroll");
    };

    window.addEventListener("scroll", onScroll);

    return () => {
      window.removeEventListener("scroll", onScroll);
    };
  }, []);

  return null;
};

非同期処理とアンマウント後の setState

Bad: アンマウント後に setState される可能性

import { useEffect, useState } from "react";

export const BadAsyncSetState = () => {
  const [data, setData] = useState("");

  useEffect(() => {
    fetch("/api/data")
      .then((r) => r.text())
      .then((text) => {
        setData(text);
      });
  }, []);

  return <div>{data}</div>;
};

Good: AbortControllerでキャンセルする

import { useEffect, useState } from "react";

export const GoodAsyncAbort = () => {
  const [data, setData] = useState("");

  useEffect(() => {
    const controller = new AbortController();

    (async () => {
      try {
        const r = await fetch("/api/data", {
          signal: controller.signal,
        });
        const text = await r.text();
        setData(text);
      } catch (e) {
        if (e instanceof DOMException && e.name === "AbortError") return;
        throw e;
      }
    })();

    return () => {
      controller.abort();
    };
  }, []);

  return <div>{data}</div>;
};

fetchはリクエストが完了すれば処理も終わるため、多くの場合は大きな問題になりません。 ただし、画面遷移後に不要な通信が走り続けたり、古いレスポンスで状態が更新されることで不具合につながる可能性もあるため、AbortController などでキャンセルできるようにしておくと安全です。


タイマーの止め忘れ

Bad: setIntervalを解除していない

import { useEffect, useState } from "react";

export const BadInterval = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setInterval(() => {
      setCount((c) => c + 1);
    }, 1000);
  }, []);

  return <div>{count}</div>;
};

Good: clearIntervalする

import { useEffect, useState } from "react";

export const GoodInterval = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = window.setInterval(() => {
      setCount((c) => c + 1);
    }, 1000);

    return () => {
      window.clearInterval(id);
    };
  }, []);

  return <div>{count}</div>;
};

requestAnimationFrame の止め忘れ

Bad: cancelできない

import { useEffect } from "react";

export const BadRAF = () => {
  useEffect(() => {
    const tick = () => {
      requestAnimationFrame(tick);
    };
    requestAnimationFrame(tick);
  }, []);

  return null;
};

Good: idを保持してcancelする

import { useEffect } from "react";

export const GoodRAF = () => {
  useEffect(() => {
    let id = 0;

    const tick = () => {
      id = requestAnimationFrame(tick);
    };

    id = requestAnimationFrame(tick);

    return () => {
      cancelAnimationFrame(id);
    };
  }, []);

  return null;
};

おわりに

副作用として外部リソースとつないだ瞬間、メモリ管理は開発者の責任になります。

Reactのメモリリーク対策は、React特有のテクニックというより、JavaScriptの基礎理解の延長にあります。適切なクリーンアップを実装することで、安定したアプリケーションを構築できます。