なんとなくでやらないReact.memo戦略
Reactを使っていると、ふと再レンダリングが気になることがあります。
そこでReactの公式サイトやインターネットを見るわけですが、多く場合useCallback
とReact.memo
を使えという旨の記事が確認できると思います👀
それはわかったとして、どういう戦略でこれらを使い不要な再レンダリングを防ぐべきかという考え方についてはあまり載っていなかった気がするので、せっかくなのでまとめておきます。
ところでどうすると再レンダリングを抑止できるか
Reactはstateが変わった時、その状態によって必要な箇所を再レンダリングします。
export default function App() {
console.log('App');
const [count, updateCount] = useState(0);
const handleUpdateCount = () => {
updateCount((count) => count + 1);
};
return (
<div className="App">
<div>{count}</div>
<button onClick={handleUpdateCount}>increment</button>
</div>
);
}
これだとボタンを押されるたびに、count
の値が変わるので、incrementボタンを押すたびに常に再レンダリングされるのはわかると思います。
例えば以下のような場合はどうでしょうか?
const ComponentA: React.FC = () => {
console.log('ComponentA');
const [count, updateCount] = useState(0);
const handleUpdateCount = () => {
updateCount((count) => count + 1);
};
return (
<div>
<div>{count}</div>
<button onClick={handleUpdateCount}>increment</button>
</div>
)
}
const ComponentB: React.FC = () => {
console.log('ComponentB');
return (<div>ComponentB</div>);
}
export default function App() {
console.log('App');
return (
<div className="App">
<ComponentA />
<ComponentB />
</div>
);
}
この時はincrementボタンを押して更新されるのはComponentA
だけです。
直感的ですね🙆🏻♂️
では以下のような場合はどうでしょうか?
const ComponentA: React.FC = ({ children }) => {
console.log('ComponentA');
const [count, updateCount] = useState(0);
const handleUpdateCount = () => {
updateCount((count) => count + 1);
};
return (
<div>
<div>{count}</div>
<button onClick={handleUpdateCount}>increment</button>
<div>
{children}
</div>
</div>
)
}
const ComponentB: React.FC = () => {
console.log('ComponentB');
return (<div>ComponentB</div>);
}
export default function App() {
console.log('App');
return (
<div className="App">
<ComponentA>
<ComponentB />
</ComponentA>
</div>
);
}
更新が起こる可能性のあるComopnentA
にchildrenでComponentB
を渡しています。
この場合もincrementボタンが押された場合、更新されるのはComponentA
だけです。
なんとなく、再レンダリングが起こる範囲についてつかめてきた感じがあります。
memoとuseCallback
上記でみたように、基本的にはstateの持ち方やコンポーネントの分割を適切に行うことで再レンダリングについては防げることがわかりました。
だけど現実問題、たくさんのstateを持つコンポーネントが出てくると思います。そしてそのコンポーネントはおそらくその子共達や孫達にstateを分配することになると思います。
その時、子供や孫にとって自分のprops以外の値が更新されたときは再レンダリングさせたくない、というのが人情だと思います。そういう場合に使うのがuseCallback
やReact.memo
です。
そういうわけで、memo
とかuseCallback
とかまだ何もされていないこんなコードを用意しました。codesandboxなりでReact + TypeScript環境を作ってApp.tsx
に貼ってもらうと動くと思います。
import "./styles.css";
import React, { useState } from "react";
type CountButtonProps = {
type: "RootA" | "RootB";
onClick: () => void;
};
const CountButton: React.FC<CountButtonProps> = ({ type, onClick }) => {
console.log(`${type}: Button`);
return <button onClick={onClick}>{type}: increment</button>;
};
type RootProps = {
count: number;
onClick: () => void;
};
const RootA: React.FC<RootProps> = ({ count, onClick }) => {
console.log("RootA");
return (
<div>
<div>RootA: {count}</div>
<CountButton type="RootA" onClick={onClick} />
</div>
);
};
const RootB: React.FC<RootProps> = ({ count, onClick }) => {
console.log("RootB");
return (
<div>
<div>RootB: {count}</div>
<CountButton type="RootB" onClick={onClick} />
</div>
);
};
export default function App() {
console.log('App');
const [countA, updateCountA] = useState(0);
const [countB, updateCountB] = useState(0);
const handleUpdateCountA = () => {
updateCountA((countA) => countA + 1);
};
const handleUpdateCountB = () => {
updateCountB((countB) => countB + 1);
};
return (
<div className="App">
<RootA count={countA} onClick={handleUpdateCountA} />
<RootB count={countB} onClick={handleUpdateCountB} />
</div>
);
}
構成としては以下のような感じですね
App - RootA - CountButton
- RootB - CountButton
この状態でCountButton
のどちらかをクリックするとApp
のstateが更新されApp
が再レンダリング対象となるので、すべてのコンポーネントが再レンダリングされます。問題ないですね。
そこでRootA
をメモ化してみようと思います。
const MemoizedRootA = React.memo(RootA);
export default function App() {
...
return (
<div className="App">
<MemoizedRootA count={countA} onClick={handleUpdateCountA} />
<RootB count={countB} onClick={handleUpdateCountB} />
</div>
);
}
しました。ただ、これだけだと変化はありません。countB
が更新された場合でもRootA
は更新されてしまいます。
理由はApp
が更新されるたびにhandleUpdateCountA
が新しく作られてしまうからです。
そういうわけで、handleUpdateCountA
をuseCallback
で依存配列に指定された値が変更されたときのみ作り直されるようにしたいと思います(今回の場合はそれぞれに依存するものがないので依存配列は空になります)。
export default function App() {
console.log("App");
const [countA, updateCountA] = useState(0);
const [countB, updateCountB] = useState(0);
const handleUpdateCountA = useCallback(() => {
updateCountA((countA) => countA + 1);
}, []);
const handleUpdateCountB = () => {
updateCountB((countB) => countB + 1);
};
return (
<div className="App">
<MemoizedRootA count={countA} onClick={handleUpdateCountA} />
<RootB count={countB} onClick={handleUpdateCountB} />
</div>
);
}
すると以下のような挙動になるはずです
RootA
のボタンをクリックしたときはRootA
、RootB
ともに更新されていますが、RootB
のボタンを押したときはRootB
のみ更新されていますね🙆🏻♂️
ただし、ボタンに渡されるhandleUpdateCountA
は依存関係がないので最初に作られてから変更はないにもかかわらず、RootA
が更新されるたびにRootA
のCountButton
が毎回更新されています。
理由はCountButton
がmemoされていないので、親であるRootA
の更新に伴って再レンダリングされてしまうからです。CountButton
をmemo
してやるとボタンは初期以外再レンダリングされなくなります。
再レンダリングを抑えるための戦略
上記で長々と眺めたことから、再レンダリングを抑えるにはいくつか方法があることがわかります。
- stateを持つ位置を工夫し、変更される範囲を限定する
-
memo
等でレンダリングをおさえる
すべてのコンポーネントはmemoするべきですか?あるいはすべての関数はuseCallbackするべきですか?
すべてのコンポーネントをmemo
し、すべての関数をuseCallback
すると再レンダリングの回数は最も抑えられるのは間違いなさそうです。
ただ、上記で眺めたとおり、そもそも再レンダリングされないコンポーネントというものも多々あり、全てmemo
する必要はなさそうです。
戦略としては、
- stateを持つ位置を下層に移す、限定する
- それでももし、stateが変更された時の影響範囲が大きいコンポーネントになる場合はstateを持ったコンポーネントとのつなぎ目を
memo
する(必要であればuseCallback
も使う)。
で足りるのではないかと思います。
このようにすると、memo
等を行う場所を非常に限定することができそうです。
気持ちとしては、どうしても再レンダリングのコストが大きく掛かってしまう場合にmemo
を検討する、くらいでいいのではないかなーと思います。
再レンダリングをすべて抑制するというよりは、再レンダリングが意図した形で行われているかに焦点を当てることが大事だと思います。
その他: 各コンポーネントはもらうpropsをできるだけプリミティブ値にする
各コンポーネントでは多くの場合何らかのpropsを受け取ると思います。
公式ドキュメントにもある通り、memoを後々利用することになった場合propsの比較は普通shallow compareで行われます。つまりオブジェクトで渡ってきても中身まで見ないということです。
const a = {};
const b = {};
console.log(a === a); // true
console.log(a === b); // false
みたいなことですね。基本的にReactで扱われる値はimutableなはずなので、そのオブジェクトがどこかで更新された場合は中身は一緒でも違うものとして扱われることになります。
NumberとかStringとかのプリミティブは実際の値が比較されるので、同じ値の場合はtrueになります挙動がわかりやすいですね。
これは変化する値の場合ということなので、もらってからコンポーネントが消失するまでどこからも変更されない値の場合はオブジェクトでもなんでもいいと思います🙆🏻♂️
参考
- React.memo を濫用していませんか? 更新頻度で見直す Provider 設計
- Before You memo()
Discussion
メモ化に関する記事ありがとうございます。
一点気になった点があったのでコメントさせていただきます。
すみません、自分もきちんと理解をしているわけではないのですが、オブジェクトの場合でもプロパティがプリミティブ型(スカラ値)であれば浅い比較でオブジェクトの中身も検証してくれるという認識でいます。
浅い比較の検証をするのであれば、
===
ではなく、react-reduxであればshallowEqual
を利用しないとうまくいかないのかなと思います。浅い比較でオブジェクトの検証をする際に気をつけるのは、オブジェクトのプロパティがオブジェクト型(つまり、多重ネスト)のケースかなと思いました。
参考:
コメントありがとうございます🙇♂️
書き方が非常にわるかったです。この部分での浅い比較で行われるpropsというのは、個々のpropを指していました🙇♂️
の、
count
やonClick
のことです。例えばもし、count
がオブジェクトだった場合中まで確認されることはないということを伝えたかったのです🙏その様子をサンプルを作ってみました🙏
確かに
shallowEqual
関数に渡されたオブジェクト同士は、一階層分だけ比較されるようですね。Reactだとこの辺が
shallowEqual
のコードになるようで、react-redux
のコードとほぼ一緒ですね。memoの更新判定がどこらへんで行われているか自信がないのですが(多分ここ? )、なんとなく挙動から予測するに、
shallowEqual
には前後のpropsが以下のようなかたちで入ることになると思います。そして、countやonClickに関しては、
is
関数なので===
で比較されるようです参考になりました。ありがとうございます。
補足させていただきます。
この例に関しては
これはReactにとっては実際に以下になります。
ComponentAがレンダリングしない限り、ComponetnAのmemorizedPropsのインスタンスが変わらないです。
CompnentAのmemoriziedPropsにComponentBの遺伝子が保存されてます。
ComponentAがレンダリングしない限り、ComponentBの遺伝子が変わることがないです。
ComponentBをレンダリングする時に、以下二つ条件全部当てはまれば、ComponentBをレンダリングしません。
例にあげたのは、Appは上層Componentだから、レンダリングされてません。そのため、ComponentBの遺伝子も再生成していません。
ComponentBをレンダリング使用する時に、遺伝子変わらないこと & Context情報が変わっていないことによって、ComponentBをレンダリングしないことになりました。ComponetBの子供をそのまま引用することになります。
上記の例は
A
をクリックしたら、Appがレンダリングされ、ComponentBの遺伝子が変わったので、ComponentBのレンダリングに入ります。B
をくりっくしたら、Appがレンダリングされなく、ComponentBの遺伝子が変わらないですが、ComponetnBのcontext情報が変わりますので、ComponentBのレンダリングに入ります。