13
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-05-17

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

image.png

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

image.png

しかしながら、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} />
    </>
  );
};

image.png

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

ただし、このやり方は上記の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でやってればそのへん問題なさそうにも思えるのですが、まずいケースが出てくる(あるいは今後出てこないことを保証できない)ということでしょうか。

13
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?