【ITニュース解説】전역 상태 업데이트로 인한 성능 병목에 관한 고찰
2025年09月28日に「Dev.to」が公開したITニュース「전역 상태 업데이트로 인한 성능 병목에 관한 고찰」について初心者にもわかりやすく解説しています。
ITニュース概要
Reactでスクロール進捗を計測する際、親コンポーネントが不要に再レンダリングされ、アプリ性能が低下する問題が発生する。これは、上位コンポーネントの状態が頻繁に更新されると、関連しない部分も再描画されるためだ。解決策は、`useState`を`useRef`に変更しレンダリングを抑制するか、機能を独立コンポーネントに分離する方法が有効である。
ITニュース解説
Webアプリケーション開発において、ユーザー体験を左右する重要な要素の一つに「パフォーマンス」がある。システムエンジニアを目指す上で、アプリケーションがスムーズに動作するよう最適化する技術は必須である。今回の話題は、Reactを使った開発で遭遇しやすいパフォーマンスの問題と、その具体的な解決策についてである。
ある開発者が、Webページのスクロール進捗をリアルタイムで測定する機能を作成した。これはuseScrollProgressというカスタムフックとして実装された。このフックの内部では、useStateを使ってスクロールの進捗状況を数値として保持していた。具体的には、スクロールイベントが発生するたびに現在のスクロール高さ、ドキュメントの総高さ、ウィンドウの長さを計算し、その結果からスクロール進捗率を算出し、useStateで管理する状態変数にセットする仕組みだった。そして、このuseScrollProgressフックは、Webアプリケーションの最も上位に位置するコンポーネントの一つであるMainPageというページコンポーネントで使用された。このMainPageは、バナー、メインコンテンツ、フッターといった様々な子コンポーネントを内部に含んでいた。
開発者は当初、この機能が原因でアプリケーションの動作が重くなることを懸念していた。その予想は、「スクロールイベントが頻繁に発生し、そのたびに複雑な計算処理が繰り返されるため、フレームレートが低下する」というものだった。実際に、Chromeの開発者ツールでパフォーマンスを測定してみると、確かに性能低下が確認された。しかし、その性能低下が具体的にどの処理から来ているのかを正確に把握するため、Reactのプロファイラーツールも用いて詳細な分析を行った。
その結果、開発者の当初の予想とは異なる、より根本的な問題が明らかになった。真の原因は、計算処理そのものの重さよりも、Reactの再レンダリングの仕組みにあった。MainPageという最上位コンポーネントでuseScrollProgressフックが利用され、そのフック内部の状態(スクロール進捗)がスクロールイベントのたびに更新されていた。Reactのデフォルトの挙動として、親コンポーネントの状態が更新されると、その親コンポーネントだけでなく、その下に連なる全ての子コンポーネントも、プロパティが変更されていなくても自動的に再レンダリングの対象となる。この仕組みにより、MainPageの状態が頻繁に更新されるたびに、本来は内容が変わっていないはずのBannerやMain、Footerといった多数の子コンポーネントまでが不必要に再レンダリングされてしまっていた。この余分な再レンダリングの繰り返しが、アプリケーション全体の処理負荷を増大させ、ユーザーが感じる画面の「カクつき」や「もたつき」といったフレームレートの低下を引き起こしていたのである。つまり、問題の本質は「状態の更新が引き起こす広範囲な再レンダリング」にあった。
この性能問題を解決するために、いくつかの効果的なアプローチが提案されている。
一つ目の解決策は、useScrollProgressフック内でスクロール進捗を管理する状態の持ち方を変えることである。具体的には、コンポーネントの再レンダリングを引き起こすuseStateの代わりに、useRefというフックを使用する方法だ。useRefはミュータブル(変更可能)な値を保持するためのもので、そのcurrentプロパティの値が変更されても、それをトリガーとしてコンポーネントが再レンダリングされることはない。この特性を利用することで、スクロール進捗の値を内部的に更新しながらも、親コンポーネントやその子コンポーネントの不要な再レンダリングを防ぐことが可能になる。ただし、useRefで管理された値が変更されても画面が自動的に更新されるわけではないため、その値を画面に表示したい場合などには、別途useEffectなどのフックと組み合わせて明示的に更新をトリガーする工夫が必要になる点には注意が必要である。
二つ目の解決策は、useScrollProgressフックを利用する部分を独立したコンポーネントとして分離することである。例えば、ScrollProgressTrackerという名前の新しいコンポーネントを作成し、その中でuseScrollProgressフックを使う。そして、このScrollProgressTrackerコンポーネントをMainPageの子要素として配置する。この方法のポイントは、MainPage直下ではなく、なるべくレンダリングツリーの末端に近い位置に配置することである。こうすることで、ScrollProgressTracker内でスクロール進捗の状態が更新されても、その影響範囲をScrollProgressTrackerコンポーネントとそのわずかな子要素のみに限定できる。結果として、MainPage全体やその下の多くのコンポーネントが不必要に再レンダリングされるのを防ぎ、パフォーマンスを改善できる。フック単体ではなく、専用のコンポーネントとして切り出すことで、状態更新の影響範囲を視覚的にも限定しやすくなる利点がある。
三つ目の解決策は、MainPageの子コンポーネント、例えばBanner、Main、Footerといった全てのコンポーネントをReact.memo()でラップすることである。React.memo()は高階コンポーネント(HOC)であり、これを使ってコンポーネントをラップすると、そのコンポーネントに渡されるプロパティ(props)が前回レンダリング時と変わっていない限り、再レンダリングをスキップする機能を提供する。つまり、親コンポーネントであるMainPageが状態更新によって再レンダリングされても、子コンポーネントに渡されるpropsが変わっていなければ、子コンポーネント自体は再レンダリングされない。これにより、不要な再レンダリングの連鎖を断ち切り、パフォーマンスを改善できる。
これらの解決策の中で、どれが最も適切かは状況によって判断すべきである。三つ目のReact.memo()を使用する方法は、確かに効果的だが、MainPageの子コンポーネント全てに適用する必要があり、多くのファイルに修正を加えることになりがちである。また、プロパティの比較が複雑になると、React.memo()自体のオーバーヘッドが大きくなる可能性もある。そのため、これは開発リソースが多くかかり、最終手段として検討すべきアプローチだと考えられる。
一方で、一つ目のuseRefへの切り替えと、二つ目の独立したコンポーネントへの分離は、より局所的な修正で効果を発揮しやすい。もし、スクロール進捗の値自体が画面に直接表示される必要がなく、内部的なロジックやログ記録のためだけに利用されるのであれば、useRefを使って状態を管理するのが非常にシンプルで効果的である。useStateによる再レンダリングの副作用を完全に回避できるため、このシナリオには最適である。しかし、もしuseScrollProgressフックが何らかのUI要素に影響を与える場合や、フック単体ではなく、そのフックがトリガーする処理を完全に隔離したい場合は、二つ目の独立したコンポーネントに分離する方法がより適切かもしれない。専用のコンポーネントとして切り出すことで、そのコンポーネントの状態変更が他の部分に波及するのを防ぎつつ、そのコンポーネント内で必要なUIの更新を効率的に行うことができる。
まとめると、アプリケーションのパフォーマンスを最適化するには、Reactにおける「状態更新」と「再レンダリング」の挙動を深く理解することが不可欠である。特に、アプリケーションの最上位レベルで頻繁に更新される状態があると、予期せぬ形で広範囲な再レンダリングが引き起こされ、性能低下の原因となる。このような問題に直面した際には、useRefを適切に活用して不要な再レンダリングを防ぐ、あるいは関連するロジックを独立したコンポーネントに分離して影響範囲を限定するといった方法をまず検討すべきである。そして、これらの方法で改善が難しい場合にのみ、React.memo()のようなより広範な修正を伴う最適化を検討するという順序でアプローチすることが、効率的な開発と高性能なアプリケーションの両立に繋がる。システムエンジニアとして、これらのパフォーマンス最適化の手法を習得し、状況に応じて最適な解決策を選択できるようになることが、より質の高いシステム構築への道となるだろう。