useEffectで依存配列とクリーンアップを整理する
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]) | a か b が変化したレンダリング後に実行 |
依存配列は「このエフェクトが内部で参照している値」を漏れなく並べるのが基本です。次の例を見てください。
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-hooks の exhaustive-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つです。
- エフェクト内で参照している値は、依存配列に漏れなく入れる(
exhaustive-depsを有効に) - 購読・タイマーを始めたら、必ずクリーンアップを返す
- レンダリング中に計算できる値は、エフェクトにしない
「外の世界と同期するときだけ使う」という原則を思い出すと、エフェクトの使いどころはぐっと判断しやすくなります。以上です。