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.memo と useCallback)
React.memo しても再レンダリングされるケース
props は Object.is で比較される。
onClick などのイベントハンドラはオブジェクトなのでレンダリング時に再生成される。これは React.memo じに渡された props とは異なるオブジェクトと判断されるので 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 = () => void を useCallback でメモ化することで <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 として描画される依存関係のないコンポーネントは再レンダリングされない
A simple composition trick to avoid re-rendering a component. pic.twitter.com/l6eckBgvU5
— Alex Sidorenko (@asidorenko_) 2022年2月5日
ラッパーコンポーネントが増えてしまうが state を持つコンポーネント内で children として依存関係のないコンポーネントを描画するようにすれば、state が更新されても children は React.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 でキャプれるアプリ知りたい…
おわり ₍ ᐢ. ̫ .ᐢ ₎
[参考]


