React useEffect メモリーリーク防止 Tip

2021/03/17に公開

React Hooks登場で、クラス型コンポーネントでLifeCycle関数で処理していた部分を関数型コンポーネントでもuseEffectを使って処理できるようになりました。
useEffectを使う時、何気に使うとメモリーリークが発生する場合があります。今回はこの問題を防ぐためのTipを紹介したいと思います。

問題状況

普通、useEffectは以下のように使います。

import { useState, useEffect } from 'react'

export default function App() {
  const [post, setPost] = useState(null)

  useEffect(() => {
    const fetchPost = async () => {
      const res = await fetch("https://jsonplaceholder.typicode.com/posts/1")
      const post = await res.json()
      setPost(post)
    }

    fetchPost()
  }, [])

  return post ? <div>{post.title}</div> : null
}

useEffectのdependency arrayに空の配列を入れることで、クラス型コンポーネントのcomponentDidMount LifeCycle関数のようにコンポーネントがMountされてから1回だけ実行しようとするコードです。
ちらっと見ると問題なさそうですが、race conditionとmemory leakの脆弱性が潜在しています。

もしサーバからResponseが来る時間が長くなり、その間にコンポーネントがUnmountされたとしたら?コンポーネントは消えたが、依然としてRequestは待機中です。 そして、Responseが来たら、setPostにpostデータをセットし、Reactは下の警告文を表示します。

postIdをdependency arrayに入れて使う場合もよくあります。

import { useState, useEffect } from 'react'

export default function App({ postId }) {
  const [post, setPost] = useState(null)

  useEffect(() => {
    const fetchPost = async () => {
      const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`)
      const post = await res.json()
      setPost(post)
    }

    fetchPost()
  }, [postId])

  return post ? <div>{post.title}</div> : null
}

この場合も同様に、postId が変更されたが、Response は依然として来ない場合、同様の問題が発生する可能性があります。

解決策 (1)

import { useState, useEffect } from 'react'

export default function App() {
  const [post, setPost] = useState(null)

  useEffect(() => {
    let isMounted = true
    const fetchPost = async () => {
      const res = await fetch("https://jsonplaceholder.typicode.com/posts/1")
      const post = await res.json()

      if (isMounted) { 
        setPost(post)
      }
    }

    fetchPost()

    return () => {
      isMounted = false
    }
  }, [])

  return post ? <div>{post.title}</div> : null
}

mount状態を示す変数(isMounted)を追加することで、Responseが遅れてもsetPostの呼び出しを防止することができます。 しかし、バックグラウンドではいくつかのRequestが飛んでいるため、race conditionの問題が発生する可能性があります。

解決策 (2)

import { useState, useEffect } from 'react'

export default function App() {
  const [post, setPost] = useState(null)

  useEffect(() => {
    let abortCtrl = new AbortController()
    const fetchPost = async () => {
      try {
        const res = await fetch("https://jsonplaceholder.typicode.com/posts/1", { signal: abortCtrl.signal })
        const post = await res.json()
        setPost(post)
      } catch (e) {
          if (e.name === "AbortError") {
          // something to do when error occur
        }
      }
    }

    fetchPost()

    return () => {
      abortCtrl.abort()
    }
  }, [])

  return post ? <div>{post.title}</div> : null
}

より確実な解決方法はhttp fetchを取り消すAbortControllerを使うこのです。
https://developer.mozilla.org/en-US/docs/Web/API/AbortController

コンポーネントがUnmountされたら、useEffectのReturnのCleanup関数によってRequestを取り消します。 対応していないブラウザもあるのでpolyfillが必要になるかもしれませんが、問題解決のための確実な方法だと思います。

Discussion