第4回

useEffectのクリーンアップ:メモリリークを防ぐ正しい実装

useEffectのクリーンアップ関数を解説します。タイマー・イベントリスナー・非同期処理のキャンセルなど、アンマウント時に必要なクリーンアップの実装方法をメモリリーク防止の観点から学べます。

·7分で読める

制作・運営: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の実装で確認できます。

React公式:Effectのライフサイクル、useEffectでの取得とcleanup

#react#hooks#useEffect#cleanup#intermediate