📌

callbackメソッドによるrerenderを防ぐhooksを作成

2023/10/14に公開
2

始めに

Reactではrerenderの条件がシビアで、メソッドもrenderの度に作り直されているため、変更検知の対象になってしまいます。これを回避するためにメソッドもuseCallbackを使うことでメモ化して再生成を抑制することはできます。

renderの度に作り直される
const Comp = () => {
  return (
    <button
      // 無名関数を直接生成して代入しているため、renderの度に新しい関数を渡してしまっている
      onClick={() => {
        console.log('clicked');
      }}
    >
      ボタン
    </button>
  )
}
メモ化で再生成を抑制する
const Comp = () => {
  // useCallbackでメモ化する
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

  return (
    <button
      // メモ化しているため、何回renderしても同じメソッドを渡してくれる
      onClick={handleClick}
    >
      ボタン
    </button>
  )
}

メモ化すれば確かに再生成を抑制することはできますが、全ての場所でそれを行わないとdepsで変更対象になってしまい、折角のメモ化も無駄になってしまいます。

export type MemoCounterProps = {
  count: number;
  onChangeCount: (newCount: number) => void;
};

export const MemoCounter: FC<MemoCounterProps> = ({ count, onChangeCount }) => {
  const handlePlusButtonClick = useCallback(() => {
    onChangeCount(count + 1);
  }, [count, onChangeCount]);

  const handleMinusButtonClick = useCallback(() => {
    onChangeCount(count - 1);
  }, [count, onChangeCount]);

  return (
    <div style={{ display: "flex" }}>
      <button onClick={handleMinusButtonClick}>-</button>
      <div style={{ padding: "0 4px" }}>{count}</div>
      <button onClick={handlePlusButtonClick}>+</button>
    </div>
  );
};

const App: FC = () => {
  const [count, setCount] = useState(0);

  return (
    <MemoCounter
      count={count}
      // 折角コンポーネント側がmemo化を意識しても、使う側でメモ化していなかったら結局作り直される
      onChangeCount={(newCount) => {
        setCount(newCount);
      }}
    />
  );
};

上記のコードは実は他にも問題があります。countが変更するときもメソッドが作り直されてしまうことです。 これはReactの仕様上どうしようもないことですが、depsの対象に含まれることで逆に不具合を起こすケースもあり、最悪 eslint-disable-next-line react-hooks/exhaustive-deps で無視するしかない場合もあります。ただこれは本当の最終手段であり、可能な限り使わずに済ませたいです。

この問題を解決するべく公式でもuseEffectEventというhooksを策定中ですが、現状ではまだ使えません。

https://ja.react.dev/learn/separating-events-from-effects#declaring-an-effect-event

現状の仕様でも何とかこの問題を解決できないか色々考えていましたが、refで一度メソッドを持っておけば上手くいくのでは?と思ったのでその辺についてまとめてみました。

callbackをrefで持ってdeps対象から外す

depsの対象になるのはリアクティブであるstateやpropsで、refは対象外になります。したがってrenderのたび一度refに退避して、メソッド実行時に退避したメソッドを呼び出すことでdeps対象から外すことができます。

refに一時退避してdeps対象から外す
 export const MemoCounter: FC<MemoCounterProps> = ({ count, onChangeCount }) => {
+  const onChangeCountRef = useRef(onChangeCount);
+  onChangeCountRef.current = onChangeCount;

   const handlePlusButtonClick = useCallback(() => {
+    onChangeCountRef.current(count + 1);
-    onChangeCount(count + 1);
-  }, [count, onChangeCount]);
+  }, [count]);

   const handleMinusButtonClick = useCallback(() => {
+    onChangeCountRef.current(count - 1);
-    onChangeCount(count - 1);
-  }, [count, onChangeCount]);
+  }, [count]);

   return (
     <div style={{ display: "flex" }}>
       <button onClick={handleMinusButtonClick}>-</button>
       <div style={{ padding: "0 4px" }}>{count}</div>
       <button isShowUpdateCallbackCount onClick={handlePlusButtonClick}>
         +
       </button>
     </div>
   );
 };

これによって onChangeCount メソッドがrenderするたびに新しくなっていても handle~ メソッドは再生成されずに済みます。
同じ要領でcountもrefに退避することでdepsから外せますが、一々refに退避するコードを書くのは手間なので、その辺をやってくれるhooksを作りたいと思います。

callbackをrefに退避してdepsを無視してメモ化できるhooks

hooksのコードは以下のようになります。callbackをrefに退避させるため、結果的に全てのパラメータをdepsの対象から除外することができました。

callbackをrefに退避してdepsを無視してメモ化できるhooks
import { useRef, useCallback } from "react";

/**
 * depsを無視して最新のstateを参照できるメモ化されたコールバックメソッドを返すhooks
 * @param callback - コールバック関数
 */
export const useMemoCallbackWithoutDeps = <
  Callback extends (...args: any) => any
>(
  callback: Callback
): Callback => {
  // refでメソッドを持つことでdeps対象から外す
  const callbackRef = useRef(callback);

  // renderのたびに最新のメソッドに更新しておく
  callbackRef.current = callback;

  // 実際に呼び出されるメソッドはuseCallbackで変更されないようにする
  const memorizedCallback = useCallback((...args: any[]) => {
    return callbackRef.current(...args);
  }, []);

  // 上手く型を合わせられなかったのでキャストする
  return memorizedCallback as Callback;
};

インターフェースは useCallback とほぼ同じで、第二引数のdepsを渡す必要になくなっただけになります。

自作hooksに差し替える
 export const MemoCounter: FC<MemoCounterProps> = ({ count, onChangeCount }) => {
+  const handlePlusButtonClick = useMemoCallbackWithoutDeps(() => {
-  const handlePlusButtonClick = useCallback(() => {
     onChangeCount(count + 1);
-  }, [count, onChangeCount]);
+  });

+  const handleMinusButtonClick = useMemoCallbackWithoutDeps(() => {
-  const handleMinusButtonClick = useCallback(() => {
     onChangeCount(count - 1);
-  }, [count, onChangeCount]);
+  });

   return (
     <div style={{ display: "flex" }}>
       <button onClick={handleMinusButtonClick}>-</button>
       <div style={{ padding: "0 4px" }}>{count}</div>
       <button isShowUpdateCallbackCount onClick={handlePlusButtonClick}>
         +
       </button>
     </div>
   );
 };

動作確認

今回作成したhooksの動作確認としてCodeSandboxで検証しましたので、こちらに貼っておきます。呼び出し側がメソッドをメモ化していなくても変更されないことが確認できると思います。

終わりに

以上がcallbackメソッドによるrerenderを防ぐhooksの紹介でした。パフォーマンス最適化においてはメモ化が大事になってきますが、どうしようもないケースが多々あったり、呼び出し時にメモ化したメソッドを渡さないといけないなど面倒なルールが出来てしまったりで頭を抱えていましたが、今回のhooksによってこういった問題から解放されると思いました。
メソッドのメモ化に苦労されている方の参考になれれば幸いです。

GitHubで編集を提案

Discussion

じょうげんじょうげん

refを経由させて依存関係をスルーさせる方法や、useEffectEventは知らなかったのでとても参考になりました!

ご存知かとは思いますが、setState()の引数は、値直渡しだけでなく現在値を受け取るコールバック関数もあるので簡単な例としてあえて採用している旨を追記したほうが良いと思いました。
以下のように書けばstateを依存関係に加えなくて済み、再レンダリングされません。

  const onChange = useCallback(() => {
    setCount((prev) => prev + 1);
  }, [setCount]);
ぬまさんぬまさん

コメントありがとうございます!
この記事の主旨としては小難しいテクニックを使わなくてもrerenderを抑制できることですのでその辺の説明を端折っていましたが、設計次第ではそもそもこの記事に書かれているような問題は回避することができるので、その辺含めて追記させていただきました!