はじめに
今回はReactを書くうえで役立つmemo化について調査します。Vueなどの他のフレームワークと比較すると最適化を行わないとパフォーマンス差を感じることがあったりしますが、どこまで最適化をやればいいのか、いつやればいいのか、どうやってやるのかについてまとめます。
対象読者
- React初学者 ~ 中級者
- バックエンドをメインにしているが、フロントエンドでReactを利用しているエンジニア
結論
Reactでは最適化は有効性がある箇所に必要なだけ書く、でまずは十分。React Compilerの登場によって純粋なコンポーネント設計ができていれば自動で最適化される。
ただし、レンダリングの仕組みに影響するため、正しいコードを書いていないとmemo化によって実行順序などに影響するためバグが発生する可能性あり。React Compilerの導入後は十分な動作確認が必要。
そもそもなぜ最適化するのか
再レンダー = 悪、というわけではありません。そもそもReactにおけるレンダーとは、「UIをどう描くかを計算する段階」を意味します。実DOMへの反映は別の段階で行われます。レンダー回数が増えても必ずしも遅いとは限らないですが、重い計算や巨大なリスト、不要な再生成が絡むと体感の劣化を招きます。
つまり、基本的には計測によって重たい箇所、いわゆるホットスポットを特定し、最適化を行うというアプローチが望ましいです。
- レンダー … 現在のprops/stateからUIを記述した仮想DOMを生成する
- コミット … 仮想DOMの差分を実DOMに反映する
という棲み分けになっており、memo化は主に「レンダーの繰り返し計算」や「子の再レンダー」を抑えるための技術です。
memo化の基本的な方法
React.memo(Component)
親が再レンダーしても、新旧propsがObject.isで等価と判定される場合は子を再レンダーしないようになります。
特に、子コンポーネントが重たい場合や大きなリストの行、高頻度で親が再レンダーする境界などで利用すると効果的です。
反対に、子が毎回新しい参照を受け取る場合など等価比較に失敗する場合や子が軽い場合にはほとんど効果がないと言えます。
useMemo(factory, deps)
再レンダー間での計算結果をキャッシュします。依存が変わった時だけfactoryが再実行され、新しい値を返します。
ほとんどの場合で十分に高速なため、重たい計算部分にのみ適用します。
useCallback(fn, deps)
関数参照自体をmemo化します。「memo化した子に渡すハンドラ」や、useEffect/useMemoの依存配列に置く関数が安定します。
React.memoでmemo化した子に渡す関数は、等価判定されますが、毎回新しい関数参照を渡すと子が再レンダーされてしまうため、useCallbackで安定化させる必要があります。 React.memoでメモ化していない場合に利用しても再レンダリングされてしまう点に注意してください。
memo化の注意点
useEffectの利用
useEffectは依存の同値性をチェックして再実行するかどうかを決めています。useMemoやuseCallbackで依存を安定化させると、意図したタイミングで実行されなくなるなど実行タイミングが変更されるため、正しく依存を厳密に書いたり、Effect自体を不要にするなどして対応する必要があります。
原則として、propsから計算可能な値を別のuseStateにコピーするようなことをしてしまうと、整合性が崩れやすいです。多くの場合はEffectで同期する処理は不要です。
不要な最適化
目に見えるボトルネックがないような軽量のコンポーネントに最適化は不要です。useMemoやuseCallback自体にもオーバーヘッドがあったり、コードが複雑化するというコストがあるため、まずは計測を行い、必要な箇所に対して最適化していきましょう。
境界を明確にする
ライブラリの公開コンポーネントなど、再利用されるほど効果が高くなります。効果が大きいと予想される箇所から最適化しましょう。
React Compilerとは
何をしてくれるのか
Reactのコードをビルド時に解析し、自動的にmemo化してくれます。これにより、手間をかけることなく再計算や再レンダーを最小化し、パフォーマンスを向上させます。
Reactらしいコードを正しく書けていれば、多くの場合、コードを書き換えずに導入できます。
特にReact.memoと同等程度の最適化を自動で適用してくれるため、多くのケースで不要になります。
導入する上での棲み分け
React Compilerを利用する前提では、まず設計をあるべき形に整えることが重要です。具体的には以下の点に注意します。
- レンダーが純粋になるように書く
- 外部ミューテーションを避ける
- 副作用を適切に分離する
手動でのmemo化はピンポイントかつ最小限にしていきます。特に重たい計算にuseMemoを使うようにします。
すでにmemo化している箇所は共存可能なので、基本的にはそのままで問題ないです。
React Compilerの導入によってどれくらい効果があるのかを計測したり、設計を純粋に保ちつつ、E2Eテストやリグレッションテストなどでの動作の担保も重要です。
まとめ
- Renderは要素ツリー、つまり仮想DOMを生成する計算段階、Commitは実DOMへの反映
- useMemo: 再レンダー間で計算結果をキャッシュするイメージ
- useCallback: 関数参照をキャッシュするイメージ。React.memoと組み合わせて使うことが多い
- React.memo: 親の再レンダー時に子の再レンダーを防ぐ。
- React Compiler: Reactコードをビルド時に解析し、自動的にmemo化してくれる。手動でのmemo化はピンポイントかつ最小限にできる。
余談
最近のReactの公式ドキュメントをみると仮想DOMや実DOMという表現がなくなっており、レンダーとコミットの概念にフォーカスしているように見えます。React Compilerの登場もあり、Reactの内部実装がより抽象化されてきている印象があります。
useCallbackは内部的にはuseMemoのシンタックスシュガーです。また、memo化したとしても必ずReactがそれを利用するわけでもないので、注意してください。
巨大リストの最適化という文脈でのReact.memoの利用を紹介しましたが、react-windowに代表される仮想化も有力な選択肢です。
