フロントエンド

useEffectで依存配列とクリーンアップを整理する

読了 7分
フロントエンド

useEffectで依存配列とクリーンアップを整理する

useEffect は使う頻度が高いわりに、つまずきやすいフックです。「とりあえず書いたら無限ループした」「リスナーが二重に登録されていた」あたりは、自分も一度は通った道なので、整理がてら書いておくメモです。

まずは依存配列とクリーンアップの2つから、useEffect の扱い方を整理していきます。

useEffectは「外の世界」とつなぐ場所

まず大前提として、useEffectレンダリングの結果として外部システムと同期するためのフック です。具体的にはこんな処理が対象になります。

  • DOMイベントの購読(addEventListener
  • タイマー(setInterval / setTimeout
  • 外部ストアやWebSocketの購読
  • ブラウザAPIの操作(タイトル変更、localStorage など)

逆に言うと、「propsやstateから計算できる値」を作るためにエフェクトは必要ありません。ここを混同すると、不要なエフェクトが増えてコードが追いにくくなります。自分も昔やりがちでした。

まず依存配列の3パターンを理解する

useEffect の第2引数(依存配列)の書き方で挙動が大きく変わります。違いは次の表のとおりです。

書き方実行タイミング
useEffect(fn)毎レンダリング後に毎回実行
useEffect(fn, [])マウント時に1回だけ実行
useEffect(fn, [a, b])ab が変化したレンダリング後に実行

依存配列は「このエフェクトが内部で参照している値」を漏れなく並べるのが基本です。次の例を見てください。

function SearchResults({ query }: { query: string }) {
  const [items, setItems] = useState<string[]>([])

  useEffect(() => {
    let active = true
    fetch(`/api/search?q=${query}`)
      .then((res) => res.json())
      .then((data) => {
        if (active) setItems(data.items)
      })
    return () => {
      active = false
    }
  }, [query])

  return <List items={items} />
}

エフェクト内で query を使っているので、依存配列に query を入れます。これで query が変わるたびに再取得が走ります。

つまずき1:依存配列の指定漏れ

いちばん多いのが「使っている値を依存配列に書き忘れる」パターンです。

// 悪い例:count を使っているのに依存配列が空
useEffect(() => {
  const id = setInterval(() => {
    console.log(count) // ずっと初期値の 0 を表示してしまう
  }, 1000)
  return () => clearInterval(id)
}, [])

依存配列が空なので、エフェクトはマウント時に作られた count(初期値)を握ったままになります。これは「古いクロージャ(stale closure)」と呼ばれる典型的なバグです。最初にこれを踏んだときは、なんで値が更新されないのか本気で悩みました。

解決策はいくつかありますが、状態の更新であれば 更新関数 を使うと依存を減らせます。

const [count, setCount] = useState(0)

useEffect(() => {
  const id = setInterval(() => {
    setCount((prev) => prev + 1) // 直前の値を引数で受け取る
  }, 1000)
  return () => clearInterval(id)
}, [])

setCount((prev) => ...) の形なら外側の count を参照しないので、依存配列を空にしても正しく動きます。なお、この種の指定漏れは eslint-plugin-react-hooksexhaustive-deps ルールが警告してくれるので、有効にしておくとよさそうです。

つまずき2:クリーンアップの漏れ

購読やタイマーを始めたら、必ず後始末(クリーンアップ)を返します。これを忘れると、アンマウント後もリスナーが残って、メモリリークや「すでに消えたコンポーネントのstateを更新しようとする」問題につながります。

useEffect(() => {
  const onResize = () => setWidth(window.innerWidth)
  window.addEventListener('resize', onResize)

  // この return がクリーンアップ。次の実行前とアンマウント時に呼ばれる
  return () => window.removeEventListener('resize', onResize)
}, [])

ここでのポイントは、クリーンアップが アンマウント時だけでなく、次にエフェクトが再実行される直前にも走る ことです。依存配列の値が変わるたびに「前回の購読を解除してから新しく購読し直す」という流れになります。これを意識すると、リスナーの二重登録を防げます。

つまずき3:そもそもエフェクトが不要なケース

意外と見落としがちですが、「propsやstateから導ける値」にエフェクトは要りません。

// 不要な例:firstName と lastName から fullName を作るのにエフェクトを使っている
const [fullName, setFullName] = useState('')
useEffect(() => {
  setFullName(`${firstName} ${lastName}`)
}, [firstName, lastName])

これはレンダリング中に普通に計算すれば十分です。

// 良い例:レンダリング中に計算する
const fullName = `${firstName} ${lastName}`

エフェクトを挟むと、一度古い値で描画してから再描画が走るぶん無駄が増えますし、コードも追いにくくなります。「この値はレンダリング中に計算できないか?」を先に考えるのが大切です。React公式のYou Might Not Need an Effectに、不要なエフェクトを見分けるパターンが詳しくまとまっています。

まとめ

useEffect を安全に使うためのチェックポイントは次の3つです。

  1. エフェクト内で参照している値は、依存配列に漏れなく入れる(exhaustive-deps を有効に)
  2. 購読・タイマーを始めたら、必ずクリーンアップを返す
  3. レンダリング中に計算できる値は、エフェクトにしない

「外の世界と同期するときだけ使う」という原則を思い出すと、エフェクトの使いどころはぐっと判断しやすくなります。以上です。