React hooksの罠について
はじめに
Reactのhooksがリリースされてまもなく5年が経とうとしています。
その一方で、まだClass Componentを使っているプロジェクトも多々あるかと思います。
私が開発しているプロジェクトでも、Class Componentが多く残されていました。
それらをFunctional Componentに置き換えていたのですが、その中で「Stale Closure」という問題に直面しました。
何番煎じかわかりませんが、この記事では、その原因と解決方法を解説してみようと思います。
Stale Closureとは?
Stale Closureと聞くと難しく感じるかもしれませんが、簡単にいうと「古いStateで動作してしまう」現象です。
Closure(クロージャ)は、関数が定義されたレキシカル環境の情報を「覚えている」特性を持つJavaScriptの機能です。
つまり、関数はそれが定義されたスコープの変数への参照をキャッチし、それを後から利用できます。
(詳細な説明はMDNのクロージャをのぞいてみてください)
しかし、Reactの関数コンポーネントとフックを使用すると、このクロージャの特性が予期しない動作を起こすことがあります。
例えば以下のよう場合です。
具体例
import React, { useState, useEffect } from 'react'; function Counter() { const [count, setCount] = useState(0); useEffect(() => { const intervalId = setInterval(() => { console.log(count); }, 1000); return () => clearInterval(intervalId); }, []); return ( <div> <p>{count}</p> <button onClick={() => setCount(count + 1)}>Increment</button> </div> ); }
上記のコードでは、setInterval
内のconsole.log
は常に0を出力します。なぜなら、useEffect
の依存配列が空なので、count
の初期値である0をキャッチしているからです。
問題の原因
この問題は、useEffect
内の関数が、それが定義された時点のcount
の値をキャッチしてしまうためです。このような挙動は、特に依存配列を正しく設定しない場合に顕著になります。
解決策
この問題を解決する一つの方法は、useEffect
の依存配列にcount
を追加することです。
useEffect(() => { const intervalId = setInterval(() => { console.log(count); }, 1000); return () => clearInterval(intervalId); }, [count]);
これにより、count
の値が更新されるたびに、新しいcount
の値を持つクロージャが作成されます。
解決策(力技)
シンプルに解決できるなら依存に入れるでいいと思いますが、1度しか実行したくないなどで難しい場合もありますよね。
(そうならないようにリファクタしろという言葉も聞こえてきそうですが...)
この場合はどうするか。こんな感じでrefを使ってみましょう。
const countRef = useRef(count); useEffect(() => { countRef.current = count }, [count]) useEffect(() => { const intervalId = setInterval(() => { console.log(countRef.current); }, 1000); return () => clearInterval(intervalId); }, []);
useRef
は、レンダー時には不要な値を参照するためのReactフックです。
これによりレンダーを跨いで情報を保存することができるため、console.log
に値が反映されます。
まとめ
なんとなくhooksを使っていたけど、ちゃんと理解しないと意外にハマる人多そうだと思いまとめてみました。
私はcomponentDidMount
の置き換えがuseEffect
の空配列だというガバ認知により、罠にハマりました。
ちゃんと公式ドキュメントを読んで理解を深めたいと思います。
(そういえば最近日本語対応されましたね)