ReactのuseEffectにおけるクリーンアップの重要性
はじめに
クリーンアップとは、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 DesignのFormコンポーネントを使用した。
useEffect(() => {
... // 他の処理
form
.validateFields()
.then((values) => {
// エラーがなかった場合の処理
})
.catch((error) => {
// エラーがあった場合の処理
});
}, [form, ...]);
しかし、このコードでは正しく動作しなかった。
formの値に問題がないにも関わらず、エラーが発生してしまうのである。
errorをコンソールに出力してみると、error.outOfDateというプロパティがtrueになっていることがわかった。
outOfDateとは、Ant DesignのFormコンポーネントにおいて、非同期でバリデーションを行っている際に、formの値が変更された場合に発生するエラーである。
今回の場合、formの値は変更されていないが、useEffectの他の依存値が変更されたことで、useEffectが再実行され、formのバリデーション処理が再実行されてしまい、outOfDateエラーが発生していた。
この問題を解決するためには、useEffectのクリーンアップ処理を行う必要があった。
修正後のコードはクリーンアップの説明後に記述する。
クリーンアップ処理を行った後のコード
isCurrentという変数を使用して、非同期処理中にuseEffectが再実行された場合は、isCurrentがfalseになるようにし、非同期処理完了後の処理はisCurrentがtrueの場合のみ実行するようにした。
このようにすることで複数回実行された場合は、最後に実行された非同期処理の結果のみを反映することができる。
useEffect(() => {
+ let isCurrent = true;
... // 他の処理
form
.validateFields()
.then((values) => {
+ if (isCurrent) {
// エラーがなかった場合の処理
+ }
})
.catch((error) => {
+ if (isCurrent) {
// エラーがあった場合の処理
+ }
});
+ return () => {
+ isCurrent = false;
+ };
}, [form, ...]);
まとめ
useEffectのクリーンアップ処理を行わないと、メモリリークや不要なリクエストが発生するなど、予期せぬバグやパフォーマンス問題を引き起こす場合がある。
useEffect内で実行しているコードが複数回実行されても問題がないか確認し、問題がある場合はクリーンアップ処理を行うことが重要である。
Discussion