かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

React の再レンダリングについてのメモ

React のコンポーネントレンダリング (Re-rendering) について試したのでメモ

前提: 親コンポーネントの state が更新されたら子コンポーネントは再レンダリングされる

import { FC, memo, useState } from 'react';

// 親と依存関係のないコンポーネント
const MyComponent: FC = () => {
  return <div>MyComponent</div>;
};

// 親から props が渡されるコンポーネント
type ButtonProps = {
  onClick: () => void;
  children: React.ReactNode;
};
const Button: FC<ButtonProps> = ({ onClick, children }) => {
  return <button type="button" onClick={onClick}>{children}</button>;
};

// メインのコンポーネント
export const Counter: FC = () => {
  const [count, setCount] = useState<number>(0);
  const handleIncrement = () => {
    setCount((prevCount) => prevCount + 1);
  };

  return (
    <div>
      <h1>Counter</h1>
      <span>{count}</span>
      <Button onClick={handleIncrement}>increment</Button>
      <MyComponent />
    </div>
  );
};

count が更新される度に Counter が再描画されその際に props が渡される <Button> も依存関係のない <MyComponent> も再描画される

props を受け取るコンポーネント (React.memouseCallback)

React.memo しても再レンダリングされるケース

propsObject.is で比較されるので onClick のイベントハンドラのようなオブジェクトはレンダリング時に再生成されるので、メモされたオブジェクトとは異なるので React.memo されたコンポーネントも再レンダリングされてしまう

import { FC, memo, useState } from 'react';

type ButtonProps = {
  onClick: () => void;
  children: React.ReactNode;
};
const Button: FC<ButtonProps> = ({ onClick, children }) => {
  return <button type="button" onClick={onClick}>{children}</button>;
};
// memo化
const MemoButton = memo(Button);

// メインのコンポーネント
export const Counter: FC = () => {
  const [count, setCount] = useState<number>(0);
  const handleIncrement = () => {
    setCount((prevCount) => prevCount + 1);
  };

  return (
    <div>
      <h1>Counter</h1>
      <span>{count}</span>
      <MemoButton onClick={handleIncrement}>increment</MemoButton>
      <MyComponent />
    </div>
  );
};

上記の例では <Button>React.memo でメモ化 (Memoization) していますが、<Counter> コンポーネントが再レンダリングされる度に、handleIncrement が再生成されるので <Counter> の再レンダリング時に <Button> も再レンダリングされてしまいメモ化の意味がありません

props のオブジェクトを useMemo, useCallback でメモ化する

オブジェクトを props として渡す場合、常に変化すつる訳ではないオブジェクトであれば useMemo, useCallback を使うことでメモされた同一のオブジェクトを渡すことができるようになる
先の例では const handleIncrement = () => voiduseCallback でメモ化することで <Button> コンポーネントに同じオブジェクトとして渡すことができる

- import { FC, memo, useState } from 'react';
+ import { FC, memo, useState, useCallback } from 'react';

// メインのコンポーネント
export const Counter: FC = () => {
  const [count, setCount] = useState<number>(0);
- const handleIncrement = () => {
+ const handleIncrement = useCallback(() => {
    setCount((prevCount) => prevCount + 1);
- };
+ }, []);
  // handleIncrement は他に依存がないので dependencies は `[]` (空配列)

  return (
    <div>
      <h1>Counter</h1>
      <span>{count}</span>
      <MemoButton onClick={handleIncrement}>increment</MemoButton>
      <MyComponent />
    </div>
  );
};

₍ ᐢ. ̫ .ᐢ ₎ yoshi!

props の場合 useCallback, useMemo でメモ化するだけではダメ

useCallback, useMemo でメモ化された prosp を渡しても、渡されるコンポーネント自体がメモ化されてないと親コンポーネントの再レンダリング時に再描画されてしまう

// メインのコンポーネント
export const Counter: FC = () => {
  const [count, setCount] = useState<number>(0);

  const handleIncrement = useCallback(() => {
    setCount((prevCount) => prevCount + 1);
  }, []);

  const handleDecrement = useCallback(() => {
    setCount((prevCount) => prevCount + -1);
  }, []);

  return (
    <div>
      <h1>Counter</h1>
      <span>{count}</span>
      <MemoButton onClick={handleIncrement}>increment</MemoButton>
      <Button onClick={handleDecrement}>decrement</Button>
      <MyComponent />
    </div>
  );
};

decrement ボタンのコンポーネントuseCallback でメモ化された handleDecrement を受け取るがコンポーネントがメモ化されてないので <Counter> の state が変化した際に親コンポーネントと一緒に再レンダリングされてしまう

親から props を渡されるコンポーネントのメモ化

props を渡されるコンポーネントReact.memo でメモ化した上で、prosp に含まれるオブジェクトが useCallback, useMemo でメモ化されている必要がある


依存関係のない子コンポーネント

React.memo でメモ化すれば再レンダリングされない

// 親と依存関係のないコンポーネント
const MyComponent: FC = () => {
  return <div>MyComponent</div>;
};
+ // メモ化
+ const MemoMyComponent = memo(MyComponent);

// メインのコンポーネント
export const Counter: FC = () => {
  const [count, setCount] = useState<number>(0);

  const handleIncrement = useCallback(() => {
    setCount((prevCount) => prevCount + 1);
  }, []);

  return (
    <div>
      <h1>Counter</h1>
      <span>{count}</span>
      <MemoButton onClick={handleIncrement}>increment</MemoButton>
-     <MyComponent />
+     <MemoMyComponent />
    </div>
  );
};

依存関係がないのでメモ化してしまえば当然、親コンポーネントの再描画されても再描画されなくなる
ただしメモ化にはコンポーネントに変化がないか計算する処理が含まれるので、必ずしもパフォーマンスが向上するわけではない

children として描画される依存関係のないコンポーネントは再レンダリングされない

ラッパーコンポーネントが増えてしまうが state を持つコンポーネント内で children として依存関係のないコンポーネントを描画するようにすれば、state が更新されても childrenReact.memo を使わなくても再描画されなくなる

// 親と依存関係のないコンポーネント
const MyComponent: FC = () => {
  return <div>MyComponent</div>;
};

// メインのコンポーネント
type CounterProps = {
  children: React.ReactNode;
};
const Counter: FC<CounterProps> = ({ children }) => {
  const [count, setCount] = useState<number>(0);

  const handleIncrement = useCallback(() => {
    setCount((prevCount) => prevCount + 1);
  }, []);

  return (
    <div>
      <h1>Counter</h1>
      <span>{count}</span>
      <MemoButton onClick={handleIncrement}>increment</MemoButton>
      {children}
    </div>
  );
};

// ラッパーコンポーネント
export const CounterContainer: FC = () => {
  return (
    <Counter>
      <MyComponent />
    </Counter>
  );
}

Counter コンポーネントと依存関係のない <MyComponent> はメモ化していないが、<Counter> が再描画される際にも再描画されることがない。おそらくラッパーである CounterContainer で呼び出されているのでこちらに紐付いて (CounterContainer の子という扱い?) いて、CounterContainer が再描画される際にしか再描画されないのではと思われる

所感

とりあえず useCallback, useMemo しておくか〜という感覚だったけど、props として渡す場合は子コンポーネントがメモ化されてないと抑制されるのは関数の再生成だけだったと改めて認識できた
Twitter で見かけた children を使う方法は知らなかったので、依存関係のないコンポーネントを内部に含めたいコンポーネントを作る際に活用したいと思いました

今回も長くなっちゃった。
キャプチャをたくさん撮ったけど Mac デフォルトだと mov なのでいい感じに gif でキャプれるアプリ知りたい…
おわり ₍ ᐢ. ̫ .ᐢ ₎


[参考]