🧹

ReactのuseEffectにおけるクリーンアップの重要性

2024/03/04に公開

はじめに

クリーンアップとは、useEffect フック内で設定したリソースを後始末(クリーンアップ)することである。

React のuseEffectフックは、コンポーネントがマウントされたときやアンマウントされたとき、または指定された値が変更されたときに副作用(データの取得、購読、DOM の変更など)を実行するために使用される。
非常に便利なフックであるが、クリーンアップ処理を行わないと、メモリリークや不要なリクエストが発生するなど、予期せぬバグやパフォーマンス問題を引き起こす場合がある。

実際に私も、useEffectのクリーンアップ処理を行わなかったことで、バグにハマってしまった経験がある。その経験も踏まえて、useEffectにおけるクリーンアップの重要性についてまとめてみた。

クリーンアップを行わないとどうなるか

下記のコードを例に、useEffectのクリーンアップを行わない場合の問題点について説明する。

import React, { useEffect } from "react";

const TimerComponent = () => {
  let seconds = 0;

  useEffect(() => {
    const interval = setInterval(() => {
      seconds++;
      console.log(seconds);
    }, 1000);
  }, []);

  return <div>Seconds: {seconds}</div>;
};

export default TimerComponent;

このコードは、TimerComponentコンポーネントがマウントされたときに、1 秒ごとにsecondsをインクリメントしてコンソールに出力する処理を行う。

一見すると問題なさそうに見えるが、実際にこのコードを実行してみると、以下のような問題が発生する。

1 秒ごとに 2 ずつ増えていることがわかる。

では、次にuseEffectの処理が何回実行されているかを確認してみる。

import React, { useEffect } from "react";

const TimerComponent = () => {
+ console.log("TimerComponent rendered");
  let seconds = 0;

  useEffect(() => {
    const interval = setInterval(() => {
      seconds++;
      console.log(seconds);
    }, 1000);

  }, []);

  return <div>Seconds: {seconds}</div>;
};

export default TimerComponent;

2 回実行(レンダリング)されていることがわかる。

useEffectが 2 回実行されているので、setIntervalも 2 回実行されていることがわかる。
そのため、intervalも 2 つ作成されており、1 秒ごとに 2 ずつ増えているのである。

クリーンアップ処理を行う

useEffectのクリーンアップ処理を行うためには、useEffectのコールバック関数内でクリーンアップ処理を行うための関数を返す必要がある。

下記にクリーンアップ処理を追加したコードを記述する。

import React, { useEffect } from "react";

const TimerComponent = () => {
  console.log("TimerComponent rendered");
  let seconds = 0;

  useEffect(() => {
    const interval = setInterval(() => {
      seconds++;
      console.log(seconds);
    }, 1000);

+   return () => {
+     clearInterval(interval);
+   };
  }, []);

  return <div>Seconds: {seconds}</div>;
};

export default TimerComponent;

returnで返された関数は、useEffectが再実行される直前に実行される。
つまり、新しいintervalが作成される前に、古いintervalがクリーンアップされる。

このコードを実行すると、useEffectは 2 回実行されているが、intervalは 1 つしか作成されないため、正しく動作することがわかる。

経験したバグ

実際に私が経験したバグについて記述する。

あるプロジェクトで、useEffectの中でformの値を非同期でバリデーションする処理を実装した。
formは、Ant DesignFormコンポーネントを使用した。

useEffect(() => {
  ...   // 他の処理

  form
    .validateFields()
    .then((values) => {
      // エラーがなかった場合の処理
    })
    .catch((error) => {
      // エラーがあった場合の処理
    });
}, [form, ...]);

しかし、このコードでは正しく動作しなかった。
formの値に問題がないにも関わらず、エラーが発生してしまうのである。
errorをコンソールに出力してみると、error.outOfDateというプロパティがtrueになっていることがわかった。

outOfDateとは、Ant DesignFormコンポーネントにおいて、非同期でバリデーションを行っている際に、formの値が変更された場合に発生するエラーである。

今回の場合、formの値は変更されていないが、useEffectの他の依存値が変更されたことで、useEffectが再実行され、formのバリデーション処理が再実行されてしまい、outOfDateエラーが発生していた。

この問題を解決するためには、useEffectのクリーンアップ処理を行う必要があった。

修正後のコードはクリーンアップの説明後に記述する。

クリーンアップ処理を行った後のコード

isCurrentという変数を使用して、非同期処理中にuseEffectが再実行された場合は、isCurrentfalseになるようにし、非同期処理完了後の処理はisCurrenttrueの場合のみ実行するようにした。

このようにすることで複数回実行された場合は、最後に実行された非同期処理の結果のみを反映することができる。

useEffect(() => {
+ let isCurrent = true;
  ...   // 他の処理

  form
    .validateFields()
    .then((values) => {
+     if (isCurrent) {
        // エラーがなかった場合の処理
+     }
    })
    .catch((error) => {
+     if (isCurrent) {
        // エラーがあった場合の処理
+     }
    });

+   return () => {
+     isCurrent = false;
+   };
}, [form, ...]);

まとめ

useEffectのクリーンアップ処理を行わないと、メモリリークや不要なリクエストが発生するなど、予期せぬバグやパフォーマンス問題を引き起こす場合がある。

useEffect内で実行しているコードが複数回実行されても問題がないか確認し、問題がある場合はクリーンアップ処理を行うことが重要である。

Discussion