React Hooksで関数の再作成を抑制する useEventCallback


React Hooksを利用した関数コンポーネントでは、useCallbackを利用することで関数をメモ化し再利用することができます。
これにより、memo化された子コンポーネントにおいて、propsの不要な更新によるre-renderを避けることができます。

useCallback利用前
// Buttonコンポーネントはpropsがshallow equalであればrenderされない
const Button = React.memo(props => {
  const { label, onClick } = props;
  console.log(`Button "${label}" is rendered`);
  return <button onClick={onClick}>{label}</button>;
});

//
export const CounterComp = props => {
  const [count, setCount] = useState(0);
  // onIncrement/onDecrement は render実行ごとに毎回異なるものが生成される
  const onIncrement = () => setCount(count + 1);
  const onDecrement = () => setCount(count - 1);
  // ゆえに下の`Button`は毎回renderされる
  return (
    <>
      <div>Count: {count}</div>
      <Button label="+" onClick={onIncrement} />
      <Button label="-" onClick={onDecrement} />
    </>
  );
};

useCallback利用後
// Buttonコンポーネントはpropsがshallow equalであればrenderされない
const Button = React.memo(props => {
  const { label, onClick } = props;
  console.log(`Button "${label}" is rendered`);
  return <button onClick={onClick}>{label}</button>;
});

//
export const CounterComp = props => {
  const [countA, setCountA] = useState(0);
  const [countB, setCountB] = useState(0);
  // onIncCountA/onIncCountB は再利用される
  const onIncCountA = useCallback(() => setCountA(countA + 1), [countA]);
  const onIncCountB = useCallback(() => setCountB(countB + 1), [countB]);
  // 片方のcounterが更新されても、もう一方の`Button`のrenderは避けられる
  return (
    <>
      <div>
        A = {countA}, B = {countB}
      </div>
      <Button label="A++" onClick={onIncCountA} />
      <Button label="B++" onClick={onIncCountB} />
    </>
  );
};

しかしながら、useCallbackを利用していても依存する変数がアップデートされていればやはりコールバックは新しく再作成されてしまい、Buttonコンポーネントにおいて不要なrenderが発生してしまいます。

これを避けるために、useEventCallback というカスタムhooksを作るという方法があります。

https://github.com/facebook/react/issues/14099#issuecomment-440013892
https://reactjs.org/docs/hooks-faq.html#how-to-read-an-often-changing-value-from-usecallback

上記の2つはそれぞれ実装がちょっと異なっていますが、やろうとしていることはだいたい同じです。
TypeScriptで型を意識して書くとこんなかんじでしょうか。

useEventCallback.ts
import { useRef, useCallback, useLayoutEffect } from 'react';

export function useEventCallback<A extends any[], R>(
  callback: (...args: A) => R,
): (...args: A) => R {
  const callbackRef = useRef<typeof callback>(() => {
    throw new Error('Cannot call an event handler while rendering.');
  });
  useLayoutEffect(() => {
    callbackRef.current = callback;
  }, [callback]);
  return useCallback(
    (...args: A) => {
      const callback = callbackRef.current;
      return callback(...args);
    },
    [],
  );
}

これ(useEventCallback)を用いると、先の例は以下のようになります

useEventCallbackを利用
// カスタムHook
function useEventCallback(callback) {
  const callbackRef = useRef();
  useLayoutEffect(() => {
    callbackRef.current = callback;
  }, [callback]);
  return useCallback((...args) => {
    const callback = callbackRef.current;
    return callback(...args);
  }, []);
}

// Buttonコンポーネントはpropsがshallow equalであればrenderされない
const Button = React.memo(props => {
  const { label, onClick } = props;
  console.log(`Button "${label}" is rendered`);
  return <button onClick={onClick}>{label}</button>;
});

//
export const CounterComp = props => {
  const [countA, setCountA] = useState(0);
  const [countB, setCountB] = useState(0);
  // onIncCountA/onIncCountB は常に同じ関数が再利用される
  const onIncCountA = useEventCallback(() => setCountA(countA + 1));
  const onIncCountB = useEventCallback(() => setCountB(countB + 1));
  // `Button`のre-renderは常に避けられる
  return (
    <>
      <div>
        A = {countA}, B = {countB}
      </div>
      <Button label="A++" onClick={onIncCountA} />
      <Button label="B++" onClick={onIncCountB} />
    </>
  );
};

とてもシンプルですし、良さげに見えます。

ただし、このやり方は上記のHooks FAQ ではあまりおすすめしない、という感じですね。

Note
We recommend to pass dispatch down in context rather than individual callbacks in props. The approach below is only mentioned here for completeness and as an escape hatch.
Also note that this pattern might cause problems in the concurrent mode. We plan to provide more ergonomic alternatives in the future, but the safest solution right now is to always invalidate the callback if some value it depends on changes.

うーん、Concurrent Modeで不具合が出るかも、ということです。
ただ、renderフェーズでcallback refをmutateするのではなくuseLayoutEffectでやってればそのへん問題なさそうにも思えるのですが、まずいケースが出てくる(あるいは今後出てこないことを保証できない)ということでしょうか。