React useEffect メモリーリーク防止 Tip
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を使うこのです。
コンポーネントがUnmountされたら、useEffectのReturnのCleanup関数によってRequestを取り消します。 対応していないブラウザもあるのでpolyfillが必要になるかもしれませんが、問題解決のための確実な方法だと思います。
Discussion