第4回
useEffectのクリーンアップ:メモリリークを防ぐ正しい実装
useEffectのクリーンアップ関数を解説します。タイマー・イベントリスナー・非同期処理のキャンセルなど、アンマウント時に必要なクリーンアップの実装方法をメモリリーク防止の観点から学べます。
制作・運営:React Guide 運営者
開始した処理を、どこで終えるか
Effectでタイマーや購読を開始するときは、対応する終了処理をcleanupとして返します。依存の変更による再setupの前と、コンポーネントが外れるときに呼ばれます。StrictModeでの追加setupにも対応できる組み合わせを作ります。
| 開始 | 終了 |
|---|---|
| setInterval | clearInterval |
| addEventListener | 同じ関数でremoveEventListener |
| WebSocket | closeと必要なハンドラー解除 |
| fetch | AbortControllerによる中断と古い結果の不採用 |
React 18以降は、アンマウント後のstate更新について以前の警告は出ません。警告がないからcleanup不要とも、state更新があるから必ずメモリリークとも判断しません。
タイマーを表示中だけ動かす
以下を src/App.tsx に置きます。閉じて開き直した際、カウントは0からです。
import { useEffect, useState } from 'react'
function Timer() {
const [ticks, setTicks] = useState(0)
useEffect(() => {
const id = setInterval(() => setTicks(n => n + 1), 1000)
return () => clearInterval(id)
}, [])
return <p>タイマー通知:{ticks}回</p>
}
export default function App() {
const [visible, setVisible] = useState(true)
return (
<main>
<button onClick={() => setVisible(v => !v)}>タイマー切り替え</button>
{visible && <Timer />}
</main>
)
}これは1秒ごとの通知回数です。ブラウザはバックグラウンドでタイマーを遅らせることがあるため、正確な経過秒数を表す時計ではありません。
通信の中断と結果の不採用を分ける
Aの取得中にBへ切り替え、BよりAが遅く返ると、Aが最新の画面を上書きする競合が起きます。cleanupで「この取得結果はもう不要」と印を付けると、通信が中断できなくても採用を防げます。
以下はコンポーネント内の抜粋です。userId、setUser、setError、setLoading と、APIが返すUserの型・形式の検証を別途用意します。
useEffect(() => {
const controller = new AbortController()
let active = true
async function load() {
setLoading(true)
setError(null)
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
if (active) setUser(data)
} catch (cause) {
if (active) setError(cause instanceof Error ? cause.message : String(cause))
} finally {
if (active) setLoading(false)
}
}
void load()
return () => {
active = false
controller.abort()
}
}, [userId])finally もactiveで守ります。古いAのfinallyが、取得中のBのloadingをfalseにするのも競合です。AbortControllerだけ加えて、finallyから無条件に更新する実装では防げません。
AbortControllerはサーバーの処理を巻き戻しません。送信済みの購入・保存をキャンセルする仕組みとは区別します。
イベント購読では同じ関数を渡す
useEffect(() => {
const handleScroll = () => console.log(window.scrollY)
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])removeEventListener側に別のアロー関数を書くと、登録した関数と一致しません。cleanupを追加した後、表示の出入りを繰り返して1回のスクロールでログが増殖しないことを確かめます。
確認する操作
- タイマーを閉じた後に処理が残らず、開き直しても増える速さが倍にならない。
- 通信を遅くしてA→Bへ切り替え、最後にBが表示される。
- HTTP 500や通信失敗でエラーを表示し、loadingが終わる。
URL変更直後の表示まで扱う取得Hookは カスタムHookの実装で確認できます。
同じテーマの教材
【解決】useEffectでasync/awaitを使う正しい方法と注意点
useEffectに直接asyncを付けてはいけない理由と、正しいasync/awaitの書き方を解説。内部関数として定義する方法・クリーンアップとの組み合わせ・競合状態(race condition)の防ぎ方まで実例付きで説明します。
useEffectの依存配列:正しい指定方法とよくある落とし穴
useEffectの依存配列を徹底解説。空配列・値の指定・オブジェクトや関数を依存配列に含めたときの問題と解決策、exhaustive-depsルールの使い方を実例付きで説明します。
useEffectの基本:外部との同期・依存配列・クリーンアップ
ページタイトルを同期する完成サンプルでuseEffectの役割を学びます。イベント処理との違い、依存配列、クリーンアップ、StrictModeでの確認方法を解説します。