実践して感じたReactパフォーマンス最適化に関する考察
この記事で伝えたいメッセージ
- React開発者ツールを上手く使うことが非常に重要である。
useCallback、useMemo、React.memoを使用するとReactのレンダリングにおいて最適化ができる。- ただし、これらを使用する際には、いつ使用すべきかの判断が重要である。
- 本当に、本当にいつ使用すべきかの判断が非常に重要である。
- 間違って使用するとむしろパフォーマンスを低下させる可能性があるためである。
記事を書くことになったきっかけ
私は良い開発者だろうか?
フロントエンド開発者でありながらユーザーを考慮しているのだろうか?
フロントエンド開発に転向してからもうすぐ1年になる。ブーストキャンプなどを経ながらたくさん学んだと思っていた。
2024年の振り返りを進めながら自分を見つめ直すと、「実装」に追われていて、それ以上のことを考慮していなかった。
ある程度実装ができるようになった今、Reactの原理探求を超えてユーザーに価値を届ける観点からアプローチする必要があった。
航海プラス フロントエンド 4期
前述の理由に加えて、実務的に深い成長をするために航海プラスに申請した。
航海プラスでは、フロントエンド開発者として成長するための様々な過程を提供している。
最初はReactとJavaScriptについて深く扱うが、その過程でリファクタリングとパフォーマンス最適化を経験できる状況に出会った。
この機会に本格的に体験しながら、パフォーマンス最適化に関する様々な話について自分なりの言葉を作ろうという考えで記事を書くことになった。
問題状況

最初に与えられたコードとディレクトリ構造である。
私が処理したロジックについては私が航海プラス課題を遂行したGitHubで確認できる。
現在のコード構造
現在のコードの構造を整理すると以下のようになる。
最適化のためのアイデア
現在のコードで私がアプローチしてみる最適化は2つである。
- コードをモジュール化する
- 関連するロジックを最適化する
最適化はuseMemo、useCallback、React.memoなどを使用してコンポーネントを最適化する方法を考えてみようと思う。
コードのモジュール化
コードを最適化する前に、可読性を高める必要がある。
型宣言の分離
現在のコードでは型宣言がコンポーネントコードと混在している。
型の場合、コンポーネントとcontextの両方で使われるため、外部に分離する必要があった。
今回のプロジェクトについてのみリファクタリングを行い、プロジェクト内のすべてのコンポーネントで使用するので、srcにtypesというフォルダを作成し、types.tsと命名して分離した。

フックの分離
現在のコードではuseAppContextというカスタムフックが存在する。
このフックはHeaderなどのコンポーネントで共通して使用されている。
これに従ってsrcにhooksフォルダを作成し、useAppContext.tsとして分離した。

コンポーネントの分離
App.tsxのコードを見ると、Header、ItemList、ComplexForm、NotificationSystemがすべてAppコンポーネント内部に存在している。
これを分離するためにcomponentsフォルダを作成し、Header.tsx、ItemList.tsx、ComplexForm.tsx、NotificationSystem.tsxとして分離した。

改善すべき事項が残っているが、大きな流れは分離した。
Barrelファイルの作成
バレルファイルは、複数のモジュールのexportを単一のimport文を使用してインポートできるようにするファイルである。
構造改善に先立ってimport、export構文を減らすためにindex.tsファイルを作成してcomponents、hooks、typesフォルダに追加した。
そしてこれに従ってimport / export文も修正する。
非常にすっきりしたことが分かる。
パフォーマンス最適化
コードを一次的にモジュール化したので、これを基に最適化を進める。
使用する最適化方法は以下の通りである。
useCallbackを使用した関数の最適化useMemoを使用した値の最適化React.memoを使用したコンポーネントの最適化
どのような基準で最適化すべきか?
最適化を今ちょうど学び始める段階として、明確な基準を持っているわけではない。
それでも、私がコードを見たときに考えられる要素は以下の通りである。
- 不必要なレンダリングの防止
- レンダリング過程での不必要なリソース浪費の防止
つまり、レンダリング自体とレンダリングで起こることに対する最適化を基準に考えることができる。
useCallbackを使用してレンダリング時の不必要な関数生成を防ぐ
前ですでにコードの構造を分析したので、まずコード的にアプローチしてみようと思う。
その前にuseCallbackはいつ使用するのか調べてみよう。
useCallbackとは?
useCallbackは関数をメモ化するフックである。
メモ化は以前に計算した値を保存することで、同じ計算を繰り返さないようにすることである。
>useCallbackは関数をメモ化して、レンダリングのたびに新しい関数を生成しないようにする。
これだけ見るとなぜこれが必要なのか分からないかもしれない。
これを理解するために理解すべき概念が「関数の同等性」である。
関数の同等性
JavaScriptでは関数はオブジェクトとして扱われる。そのため関数は参照値を持つ。
2つのオブジェクトが同じ値を持っていても、メモリアドレスが異なれば異なるオブジェクトとして扱われるように、
関数も同じロジックを持っていてもメモリアドレスが異なれば異なる関数として扱われる。
特定の関数を他の関数の引数として渡したり、子コンポーネントのpropsとして渡したりするとき、関数の参照が異なるため予期しない問題が発生することがある。
上記のコードで関数の同等性によって発生する問題を確認できる。
useCallbackはこのような問題を解決するために使用される。
コードにuseCallbackを適用する
App.tsxでlogin、logout、addNotification、removeNotification関数が存在する。
これらの関数はuseAppContextで使用されており、これらの関数はAppコンポーネントがレンダリングされるたびに新しい関数を生成する。
これを防ぐためにuseCallbackを使用して関数をメモ化できる。
この時、login、logout関数はaddNotification関数を使用しているため、addNotification関数を依存配列に追加する必要がある。
戻り値の最適化
useMemoは関数が返す値をメモ化するフックである。
関数に対する最適化をしたので、次はuseMemoを利用して関数が返す値に対する最適化をする番である。
通常useMemoは計算量が多い関数や、レンダリングのたびに計算が必要な値に使用される。
これを基準にして、演算が複雑なのに不必要に毎回計算される値をuseMemoを使用して最適化してみよう。
App.tsxにuseMemoを適用する
App.tsxではcontextValueを提供する。
コンテキスト値オブジェクトは毎回のレンダリングで新しいオブジェクトとして生成されるため、useMemoを使用して最適化できる。
ItemList.tsxにuseMemoを適用する
ItemListコンポーネントでfilteredItems、totalPrice、averagePriceの値が存在する。
これらの値はレンダリングのたびに計算される値なので、useMemoを使用して最適化できる。
React.memoを適用する
React.memoは関数型コンポーネントをメモ化して不必要な再レンダリングを防ぐ高階コンポーネント(HOC)である。
コンポーネントが同じpropsで再レンダリングされるとき、以前にレンダリングされた結果を再利用してパフォーマンスを最適化できる。
特に、コンポーネントが重い演算を行ったり、頻繁に再レンダリングされる可能性がある場合に有用である。
適用方法は簡単である。関数型コンポーネントをReact.memoでラップするだけでよい。
上記のようにラップするだけでよい。
App.tsxはルートなので、これを除いてラップする。
レンダリングの最適化
関数と値に対する最適化をしたので、次はReact.memoの使用とコンポーネント構造の修正を通じてレンダリング最適化を進める。
React開発者ツールの探求で学んだReact開発者ツールを活用する。
開発者ツールを通じて把握した追加の問題

あるイベントがトリガーされると画面のすべてのコンポーネントがレンダリングされる問題が発生している。
Contextに基づく問題の把握
現在の構造で全体の再レンダリングが起こる最大の理由は、AppContextにすべての状態と関数が1つのコンテキストとして提供されているためである。
コンテキストの値が変更されるたびにすべてのコンテキストコンシューマー(Consumer)が再レンダリングされる。
これはReactのContext APIの基本動作のためで、コンテキストの値が変更されるとそのコンテキストを購読しているすべてのコンポーネントが再レンダリングされる方式のためである。
これによりテーマの変更やユーザー認証状態の変更時に不必要なコンポーネントの再レンダリングが発生する。
Contextの分離
これを解決するためにはAppContextを複数のコンテキストに分離して、コンテキストの値が変更されるたびにそのコンテキストを購読しているコンポーネントのみが再レンダリングされるようにする必要がある。
AppContextを分離するためにThemeContext、UserContext、NotificationContextに分けよう。
contextsフォルダを作成して、これらをそれぞれモジュール化する。
可読性のために1つのファイルに書いたが、実際にはそれぞれ異なるファイルに分離した。

その後、分離したコンテキストを各コンポーネントに適用すればよい。
結果

最適化を進めた結果、再レンダリングが減少したことを確認できる。
レンダリングで大きな違いがないように見えるが、実際にはコンテナのみが再レンダリングされるだけで、全体的なレンダリング自体が大幅に減少した。
すべてがレンダリングされていたのが、部分的にレンダリングされるようになった。

パフォーマンス測定自体はダークモード/ライトモードの切り替えを基準にした。
右側のパネルで確認できるように、最適化後にRender durationが21.8msから11.6msに減少したことが分かる。
考察
教育を受けながらも学んだことだが、実はuseCallback、useMemo、React.memoによる最適化は簡単なコードの場合は効果が微々たることがあるという。
実際に、今回の結果でも違いが非常に微々たることが分かる。
どの機能を実行するかによって、むしろ簡単な機能なのに追加で実行しなければならないモジュールが多くてレンダリング時間がさらにかかることもあった。
最適化は必要な場所にのみするのが良い。
useCallback、useMemo、React.memoは上手く使えばパフォーマンスが改善されるが、間違って使えばパフォーマンスが低下する可能性がある。
この言葉の意味がよく分からなかったのだが...今回直接やってみて感じることができたようだ。

これはログインボタンを押した時だが、むしろ最適化後のパフォーマンスがさらに悪くなったことが分かる。
まとめ:今後どうするか?
今回ちゃんと使ってみて学んだので、今後は「いつ」最適化すべきかについて考えていきたいと思う。
最適化技法に関する勉強も続けていく必要があるが、何よりもいつ最適化すべきかの判断が重要だと思われるので、このような訓練を継続的に続けていきたいと思う。
整理
useCallback、useMemo、React.memoを使用するとReactのレンダリングにおいて最適化ができる。- ただし、これらを使用する際には、いつ使用すべきかの判断が重要である。