2秒ごとにカウンタをインクリメントするカウンターアプリを例にuseCallback
の落とし穴について教えてもらった
例とするカウンターアプリの機能
・2秒ごとにカウンタをインクリメントする
・リセットボタンが押されたら
・カウンタを0にする
・インターバルをリスタートする
サンプル実装
export default function App() { const { count, onReset } = useCounter(); return ( <div className="App"> <h1>{count}</h1> <button onClick={onReset}>Reset</button> </div> ); }
カスタムフック
function useCounter() { const [count, setCount] = React.useState(0); let intervalId; function resetInterval() { clearInterval(intervalId); intervalId = setInterval(function timedout() { setCount((v) => v + 1); }, 2000); } const onReset = React.useCallback(function callbackCb() { setCount(0); resetInterval(); }, []); React.useEffect(function effectCb() { resetInterval(); return function cleanup() { clearInterval(intervalId); }; }, []); return { count, onReset }; }
まず、上のコードは想定通り動くのだが、もしonReset
に
useCallback()
を使用しない場合はどうなるかという問いがあった
const onReset = function callbackCb() { setCount(0); resetInterval(); };
これは、想定どおりの挙動にならない
なぜかというと、
onReset
がresetInterval()
を参照していてクロージャになっているresetInterval()
もintervalId
を参照していてクロージャになっている- intervalIdは普通の変数なので、関数呼び出し毎に別の領域が割り当てられる
つまり、useCallback()
無しの場合、リセットボタンを押された時に参照されるintervalIdが異なるので想定通り動かない
しかし、useCallback有が正しいわけではない
この問題の本質はuseCallback
を使うのが正というわけではなく、useCallback
を使っている場合も危険があるということ
何が問題かというと、関数呼び出しに跨がって共通のintervalIdを参照・更新したいintervalId
を共有する手段としてクロージャを使っていることが問題である
上の実装はuseCallback()
/useEffect()
に渡す関数がキャプチャする変数に依存している
つまり、useCallback()
の副作用に依存している
また、useCallback()
/useEffect()
の依存配列が同じでなければならない『暗黙的な結合』になっている
useMemo()/useCallback()の副作用に依存してはならない
React Hooksのドキュメントより
useMemo はパフォーマンス最適化のために使うものであり、意味上の保証があるものだと考えないでください。将来的に React は、例えば画面外のコンポーネント用のメモリを解放するため、などの理由で、メモ化された値を「忘れる」ようにする可能性があります。useMemo なしでも動作するコードを書き、パフォーマンス最適化のために useMemo を加えるようにしましょう。 https://ja.reactjs.org/docs/hooks-reference.html#usememo
依存していると将来のReactで動かなくなる可能性を示唆している
→useCallback()
を使った方は現在はちゃんと動くが、よい実装ではない
解決策はuseRef()を使う
useRef()の用途
https://ja.reactjs.org/docs/hooks-reference.html#useref
- コンポーネントのrefに渡すオブジェクトを生成するため
- (実) DOMにアクセスする手段
- フォーカス制御、アニメーション、領域のサイズ取得、等々
- 任意の書き換え可能な値を保存するため
- クラスコンポーネントのインスタンス変数に相当
useRef()
は関数呼び出しに跨がって常に同じオブジェクトを返す
- 再レンダリングが発生しない
useState()
として使える
この2つめの使い方になる
class component時代のインスタンス変数の使い方ができる
function useCounter() { const [count, setCount] = React.useState(0); const intervalId = React.useRef(); function resetInterval() { clearInterval(intervalId.current); intervalId.current = setInterval(function timedout() { setCount((v) => v + 1); }, 2000); } function onReset() { setCount(0); resetInterval(); } React.useEffect(function effectCb() { resetInterval(); return function cleanup() { clearInterval(intervalId.current); }; }; return { count, onReset }; }
ただし、関数コンポーネントを使う方針になっているのに、classコンポーネント時代のインスタンス変数を使った副作用バリバリの実装になりかねないので、用法容量を守って使うのが大事