ReactのMemoizationテクニックによるパフォーマンス最適化
Min-jun Kim
Dev Intern · Leapcell

はじめに
フロントエンド開発のダイナミックな世界では、スムーズで応答性の高いユーザーエクスペリエンスを提供することが最も重要です。宣言的でコンポーネントベースのライブラリであるReactは、複雑なUIの構築を容易にします。しかし、アプリケーションが大規模かつ複雑になるにつれて、不要なコンポーネントの再レンダリングがパフォーマンスの大きなボトルネックとなる可能性があります。これらの再レンダリングは、ローディングの遅いインターフェイス、CPU使用率の増加、ユーザーエクスペリエンスの低下につながる可能性があります。この記事では、これらの過剰な再レンダリングを防ぐためのmemoizationテクニック(特にReact.memo
、useCallback
、useMemo
)の重要な役割を掘り下げ、Reactアプリケーションを最適化し、より高速で効率的なユーザーインターフェイスを保証します。
コアコンセプトの理解
memoizationの仕組みを詳しく説明する前に、これらの最適化テクニックの基盤となるいくつかの基本的な概念を明確に理解しましょう。
再レンダリング: Reactでは、コンポーネントの状態またはpropsが変更されると、「再レンダリング」されます。親コンポーネントが再レンダリングされると、デフォルトでは、そのpropsが実際に変更されたかどうかに関係なく、すべての子コンポーネントも再レンダリングされます。この再レンダリングの連锁は、パフォーマンスの問題の一般的な原因です。
Memoization: 基本的に、memoizationは、高価な関数呼び出しの結果を格納し、同じ入力が再度発生したときにキャッシュされた結果を返すことにより、コンピュータープログラムを高速化するために使用される最適化テクニックです。Reactでは、この概念をコンポーネントや関数に適用して、コストのかかる操作の再実行を回避します。
参照の等価性: この概念は、Reactでmemoizationがどのように機能するかを理解する上で重要です。JavaScriptでは、オブジェクトと配列は値ではなく参照によって比較されます。これは、同じプロパティを持つ2つのオブジェクトが異なるメモリ位置にある場合、それらは等しくないと考えられることを意味します。たとえば、{} === {}
は false
に評価されます。多くの一般的なパフォーマンスの落とし穴は、コンテンツが変更されていない場合でも、レンダリングごとに意図せず新しいオブジェクトまたは配列参照を作成することから生じます。
不要な再レンダリングの防止
Reactは、コンポーネント用のReact.memo
、関数用のuseCallback
、値用のuseMemo
という、memoizationのための3つの強力なツールを提供します。それぞれを実践的な例で詳しく見ていきましょう。
コンポーネント最適化のためのReact.memo
React.memo
は、関数コンポーネントをラップする高階コンポーネント(HOC)です。コンポーネントのレンダリング出力を「memoize」し、最後のレンダリング以降のpropsが浅く変更された場合にのみコンポーネントを再レンダリングします。これは、レンダリング中に同じpropsを頻繁に受け取るプレゼンテーションコンポーネントに特に役立ちます。
メッセージを表示するシンプルなChildComponent
を考えてみましょう。
import React from 'react'; const ChildComponent = ({ message }) => { console.log('ChildComponent re-rendered'); return <p>{message}</p>; }; export default ChildComponent;
次に、ParentComponent
で使用してみましょう。
import React, { useState } from 'react'; import ChildComponent from './ChildComponent'; const ParentComponent = () => { const [count, setCount] = useState(0); const fixedMessage = "Hello from child!"; return ( <div> <h1>Parent Count: {count}</h1> <button onClick={() => setCount(prev => prev + 1)}>Increment Count</button> <ChildComponent message={fixedMessage} /> </div> ); }; export default ParentComponent;
「Increment Count」ボタンをクリックすると、ParentComponent
が再レンダリングされます。その結果、ChildComponent
も再レンダリングされ、message
propsが変更されていなくても、コンソールに「ChildComponent re-rendered」と表示されます。
この不要な再レンダリングを防ぐために、ChildComponent
をReact.memo
でラップできます。
import React from 'react'; const ChildComponent = ({ message }) => { console.log('Memoized ChildComponent re-rendered'); return <p>{message}</p>; }; export default React.memo(ChildComponent); // <--- ここでReact.memoを適用
これで、ParentComponent
がcount
の変更により再レンダリングされた場合、ChildComponent
はmessage
propsが変更された場合にのみ再レンダリングされます。fixedMessage
は一定であるため、count
を更新してもコンソールログは表示されなくなります。
React.memo
を使用する場合:
- プレゼンテーションコンポーネント: 主にデータを表示し、内部状態が最小限のコンポーネント。
- レンダリングにコストがかかるコンポーネント: コンポーネントのレンダリングロジックが計算上集中的な場合。
- 頻繁に変更されないpropsを受け取るコンポーネント: 親コンポーネントが頻繁に再レンダリングされても、子コンポーネントがほとんど変更されないpropsを受け取る場合。
注意点: React.memo
はpropsの浅い比較を実行します。propsがオブジェクトまたは配列で、その参照が同じでも内容が変更された場合、React.memo
は再レンダリングを防ぎません。ここでuseCallback
とuseMemo
が登場します。
関数のMemoizationのためのuseCallback
コールバック関数を子コンポーネント(特にmemoizeされたコンポーネント、React.memo
でラップされたものなど)に渡す場合、親の再レンダリングごとにその関数の参照が変わらないようにすることが重要です。参照が変わると、子コンポーネントは依然として再レンダリングされ、React.memo
の利点が無効になります。useCallback
は、関数自体をmemoizeすることによって役立ちます。
関数をChildComponent
に渡すために、例を修正してみましょう。
import React, { useState } from 'react'; const MemoizedChildComponent = React.memo(({ onClick }) => { console.log('MemoizedChildComponent re-rendered'); return <button onClick={onClick}>Click Child</button>; }); const ParentComponent = () => { const [count, setCount] = useState(0); const handleClick = () => { console.log('Child button clicked!'); }; return ( <div> <h1>Parent Count: {count}</h1> <button onClick={() => setCount(prev => prev + 1)}>Increment Count</button> <MemoizedChildComponent onClick={handleClick} /> </div> ); }; export default ParentComponent;
MemoizedChildComponent
はmemoizeされていますが、ParentComponent
が再レンダリングされると、新しいhandleClick
関数の参照が作成されます。onClick
propsの参照が変わるため、MemoizedChildComponent
は依然として再レンダリングされます。
これを修正するために、useCallback
を使用します。
import React, { useState, useCallback } from 'react'; const MemoizedChildComponent = React.memo(({ onClick }) => { console.log('MemoizedChildComponent re-rendered'); return <button onClick={onClick}>Click Child</button>; }); const ParentComponent = () => { const [count, setCount] = useState(0); const handleClick = useCallback(() => { // <--- 関数をmemoizeします console.log('Child button clicked!'); }, []); // 空の依存配列は、一度だけ作成されることを意味します return ( <div> <h1>Parent Count: {count}</h1> <button onClick={() => setCount(prev => prev + 1)}>Increment Count</button> <MemoizedChildComponent onClick={handleClick} /> </div> ); }; export default ParentComponent;
これで、handleClick
はmemoizeされます。依存関係(この場合は []
のためなし)が変わらない限り、useCallback
は再レンダリングごとに同じ関数インスタンスを返します。これにより、MemoizedChildComponent
はonClick
props(関数参照)が実際に変更された場合にのみ再レンダリングされることが保証されます。
依存配列: useCallback
の2番目の引数は依存配列です。この配列内の値が変更されると、useCallback
は新しい関数インスタンスを返します。memoizeされた関数内で使用されているコンポーネントスコープ内のすべての値を含めることが重要です。依存関係を忘れると、古いクロージャ(古い値を使用する関数)につながる可能性があります。
値のMemoizationのためのuseMemo
useMemo
はuseCallback
に似ていますが、関数をmemoizeする代わりに、計算された値をmemoizeします。これは、高価な計算や、memoizeされた子コンポーネントにpropsとして渡すときに参照の等価性を維持する必要があるオブジェクト/配列リテラルの作成に役立ちます。
高価な計算があるシナリオを想像してみてください。
import React, { useState, useMemo } from 'react'; const MemoizedChildComponent = React.memo(({ data }) => { console.log('MemoizedChildComponent re-rendered'); return ( <ul> {data.map(item => <li key={item}>{item}</li>)} </ul> ); }); const ParentComponent = () => { const [count, setCount] = useState(0); const expensiveCalculation = (num) => { console.log('Performing expensive calculation...'); let result = 0; for (let i = 0; i < 100000000; i++) { result += i; } return result + num; }; const calculatedValue = expensiveCalculation(count); // この計算はレンダリングごとに実行されます return ( <div> <h1>Parent Count: {count}</h1> <button onClick={() => setCount(prev => prev + 1)}>Increment Count</button> <p>Expensive Result: {calculatedValue}</p> <MemoizedChildComponent data={['item1', 'item2']} /> </div> ); }; export default ParentComponent;
ここでは、expensiveCalculation
はParentComponent
が再レンダリングされるたびに実行されます。count
(計算の入力)がexpensiveCalculation
が最後に実行されたときから変更されていなくても実行されます。
useMemo
を使用してこれを最適化できます。
import React, { useState, useMemo } from 'react'; const MemoizedChildComponent = React.memo(({ data }) => { console.log('MemoizedChildComponent re-rendered'); return ( <ul> {data.map(item => <li key={item}>{item}</li>)} </ul> ); }); const ParentComponent = () => { const [count, setCount] = useState(0); const expensiveCalculation = (num) => { console.log('Performing expensive calculation...'); let result = 0; for (let i = 0; i < 100000000; i++) { result += i; } return result + num; }; const calculatedValue = useMemo(() => { // <--- 値をmemoizeします return expensiveCalculation(count); }, [count]); // 'count'が変更された場合にのみ再計算します // 参照の等価性を維持するためにオブジェクトリテラルをmemoizeする例 const listData = useMemo(() => ['item1', 'item2', `Count: ${count}`], [count]); return ( <div> <h1>Parent Count: {count}</h1> <button onClick={() => setCount(prev => prev + 1)}>Increment Count</button> <p>Expensive Result: {calculatedValue}</p> <MemoizedChildComponent data={listData} /> </div> ); }; export default ParentComponent;
これで、useMemo
内のexpensiveCalculation
はcount
が変更された場合にのみ実行されます。それ以外の場合は、useMemo
は以前に計算された値を返します。同様に、listData
はcount
が変更された場合にのみ新しい参照を受け取るため、MemoizedChildComponent
が不要に再レンダリングされるのを防ぎます。
useMemo
を使用する場合:
- 高価な計算: 値がpropsまたは状態から導出され、計算が計算上集中的な場合。
- 参照の等価性の維持: memoizeされた子コンポーネントにオブジェクトまたは配列をpropsとして渡す場合、不要な子コンポーネントの再レンダリングを防ぐため。
重要事項: useMemo
とuseCallback
は無差別にすべきではありません。Reactフックにはある程度のオーバーヘッドがあります。ボトルネックを特定したパフォーマンス最適化や、memoizeされた子コンポーネントの参照の等価性を維持するために、主にそれらを使用してください。過剰な使用は、memoizationチェックのオーバーヘッドのために、複雑さを増したり、パフォーマンスをわずかに低下させたりする可能性があります。
結論
React.memo
、useCallback
、useMemo
は、React開発者のツールキットにおいて、非常にパフォーマンスの高いアプリケーションを構築するための貴重なツールです。これらのmemoizationテクニックをインテリジェントに適用することで、不要なコンポーネントの再レンダリングを効果的に回避し、計算オーバーヘッドを大幅に削減し、よりスムーズで応答性の高いユーザーエクスペリエンスを提供できます。これらのフックを戦略的に活用することで、高価な操作が再実行されるのを防ぎ、Reactアプリケーションの効率と応答性を最適化できます。