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 でキャプれるアプリ知りたい…
おわり ₍ ᐢ. ̫ .ᐢ ₎
[参考]