UGA Boxxx

つぶやきの延長のつもりで、知ったこと思ったこと書いてます

【React】useCallbackの落とし穴

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 };
}

まず、上のコードは想定通り動くのだが、もしonResetuseCallback()を使用しない場合はどうなるかという問いがあった

  const onReset = function callbackCb() {
    setCount(0);
    resetInterval();
  };

これは、想定どおりの挙動にならない

なぜかというと、

  • onResetresetInterval()を参照していてクロージャになっている
  • 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

  1. コンポーネントのrefに渡すオブジェクトを生成するため
  2. (実) DOMにアクセスする手段
    • フォーカス制御、アニメーション、領域のサイズ取得、等々
  3. 任意の書き換え可能な値を保存するため
  4. クラスコンポーネントインスタンス変数に相当
    • useRef()は関数呼び出しに跨がって常に同じオブジェクトを返す
  5. レンダリングが発生しない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コンポーネント時代のインスタンス変数を使った副作用バリバリの実装になりかねないので、用法容量を守って使うのが大事