フロントエンドパフォーマンスのためのuseMemoとuseCallbackの理解と効果的な活用
Ethan Miller
Product Engineer · Leapcell

はじめに
フロントエンド開発の活気に満ちた進化し続ける状況において、Reactはその基盤技術としての地位を確立しています。アプリケーションが複雑化し、ユーザーが迅速で反応の良いインターフェイスを期待するようになるにつれて、パフォーマンス最適化は便宜的なものから必須のものへと移行します。Reactがパフォーマンスチューニングのために提供する無数のツールの中で、useMemoとuseCallbackはしばしば議論に登場します。しかし、それらの真の影響と適切な適用はしばしば誤解されています。開発者は、過剰に最適化したり、逆に、それらが真にアプリケーションに利益をもたらす時期と方法を明確に理解せずに、知覚される複雑さのためにそれらを避けたりするかもしれません。この記事は、useMemoとuseCallbackの謎を解き明かし、それらの仕組み、実用的なユースケースを案内し、それらが真に高性能なReactアプリケーションを構築する上での味方であるかどうかを判断するのに役立ちます。
コアコンセプト解説
最適化の可能性を掘り下げる前に、useMemoとuseCallbackの根底にある主要Conceptsの基本的な理解を確立しましょう。
参照による等価性(Referential Equality)
useMemoとuseCallbackの中心にあるのは、参照による等価性という概念です。JavaScriptにおいて、プリミティブ型(文字列、数値、ブール値、null、undefined、シンボル、BigInt)は値によって比較されます。しかし、非プリミティブ型(オブジェクト、配列、関数)はメモリ上の参照によって比較されます。同じ内容を持つ2つのオブジェクトでも、異なるメモリ位置を占有している場合、参照によって等しくありません。
const obj1 = { a: 1 }; const obj2 = { a: 1 }; console.log(obj1 === obj2); // false const arr1 = [1, 2, 3]; const arr2 = [1, 2, 3]; console.log(arr1 === arr2); // false const func1 = () => console.log('hello'); const func2 = () => console.log('hello'); console.log(func1 === func2); // false (func1とfunc2がまったく同じ関数インスタンスを指していない限り)
Reactは、プロパティまたは状態が変更されたかどうかを判断するために参照による等価性を使用します。オブジェクトまたは関数であるプロパティが、その内部内容は同じでも、参照を変更した場合、Reactはそのプロパティを新しいものとみなし、子コンポーネントを再レンダリングする可能性があります。
メモ化(Memoization)
メモ化は、高価な関数呼び出しの結果を保存し、同じ入力が再度発生したときにキャッシュされた結果を返すことによって、主にコンピュータプログラムを高速化するために使用される最適化技術です。本質的には、関数の戻り値に対するキャッシングの一種です。
Reactの差分検出(Reconciliation)プロセス
Reactコンポーネントは、デフォルトで、状態またはプロパティが変更されるたびに再レンダリングされます。Reactの仮想DOMと差分検出アルゴリズムは非常に効率的ですが、不要な再レンダリングは、特に複雑なコンポーネントや多数の子を持つコンポーネントの場合、パフォーマンスのボトルネックにつながる可能性があります。useMemoとuseCallbackは、Reactが冗長な作業を回避するのを助けることによって、このプロセスを最適化することに貢献します。
useMemoフック
useMemoは、再レンダリング間で計算結果をキャッシュできるようにするReactフックです。これには、「作成」関数と依存配列の2つの引数が渡されます。作成関数は、依存関係のいずれかが変更された場合にのみ再実行されます。
import React, { useMemo } from 'react'; function MyComponent({ list }) { // `expensiveCalculation` は `list` の参照が変わった場合にのみ再実行されます const expensiveResult = useMemo(() => { console.log('Performing expensive calculation...'); return list.map(item => item * 2); // 高価な操作の例 }, [list]); return ( <div> {expensiveResult.map(item => ( <span key={item}>{item} </span> ))} </div> ); }
この例では、expensiveResultはメモ化されています。MyComponentがlist以外のプロパティ(または内部状態)の変更によって再レンダリングされた場合、list.map操作は再実行されず、キャッシュされたexpensiveResultが代わりに使用されます。これにより、計算時間が節約されます。
useCallbackフック
useCallbackは、再レンダリング間で関数定義をメモ化できるようにするReactフックです。これは本質的には、関数専用のuseMemoです。関数と依存配列を受け取ります。依存関係のいずれかが変更された場合にのみ変更される、コールバックのメモ化されたバージョンを返します。
import React, { useState, useCallback } from 'react'; function ParentComponent() { const [count, setCount] = useState(0); // `handleClick` は `count` の参照が変わった場合にのみ再作成されます(数値では可能性は低いですが) // または依存関係(この場合はなし、通常は外部状態/プロパティ)が変わった場合。 const handleClick = useCallback(() => { setCount(c => c + 1); }, []); // 空の依存配列は、この関数が一度作成され、決して変更されないことを意味します return ( <div> <p>Count: {count}</p> <ChildComponent onClick={handleClick} /> </div> ); } function ChildComponent({ onClick }) { console.log('ChildComponent rendered'); // ParentComponentが再レンダリングされるとレンダリングされます(メモ化されない限り) return <button onClick={onClick}>Increment</button>; }
ParentComponentでは、handleClickはuseCallbackを使用して作成されます。空の依存配列([])を使用すると、この関数インスタンスはParentComponentが最初にレンダリングされたときに一度だけ作成されます。その後のParentComponentの再レンダリングでhandleClickが再定義されることはなく、参照による等価性が維持されます。
useMemoとuseCallbackが真に最適化するのはいつか
これらのフックの真の価値は、特定のシナリオで不要な作業や再レンダリングを防ぐ場合に発揮されます。
メモ化された子コンポーネントの再レンダリング防止
これは最も一般的で影響力のあるユースケースです。React.memo(またはPureComponentを継承したクラスコンポーネント)でラップされた子コンポーネントにオブジェクトまたは関数をプロパティとして渡す場合、useMemoとuseCallbackが不可欠になります。これらがないと、プロパティの_内容_が論理的に変更されていなくても、親の再レンダリングごとにその_参照_が変更され、メモ化された子に再レンダリングを強制します。
useCallbackの例:
最適化のためにReact.memoを使用するButtonコンポーネントを考えてみましょう。
import React, { useState, useCallback, memo } from 'react'; // メモ化された子コンポーネント const MyButton = memo(({ onClick, label }) => { console.log('Button rendered:', label); return <button onClick={onClick}>{label}</button>; }); function Container() { const [count, setCount] = useState(0); const [toggle, setToggle] = useState(false); // useCallbackがないと、handleIncrementはレンダリングごとに新しい関数になり、 // MyButtonを不要に再レンダリングさせる原因となります。 const handleIncrement = useCallback(() => { setCount(prevCount => prevCount + 1); }, []); // 依存関係:なし、setCountは安定しているため // 状態変数に依存する関数 const handleToggle = useCallback(() => { setToggle(prevToggle => !prevToggle); }, []); return ( <div> <p>Count: {count}</p> <p>Toggle: {toggle ? 'On' : 'Off'}</p> <MyButton onClick={handleIncrement} label="Increment Count" /> <MyButton onClick={handleToggle} label="Toggle" /> <button onClick={() => setCount(count + 10)}>Force Parent Re-render</button> </div> ); }
「Force Parent Re-render」がクリックされ(countが更新され、Containerの再レンダリングを引き起こす)、あなたは「Button rendered: Increment Count」と「Button rendered: Toggle」がログされないことを観察するでしょう。これは、handleIncrementとhandleToggleが参照による等価性を保持しており、MyButtonがメモ化されているためです。useCallbackがない場合、両方のボタンが再レンダリングされます。
useMemoの例:
同様に、プロパティとして渡されるオブジェクトに対しても同様です。
import React, { useState, useMemo, memo } from 'react'; const UserCard = memo(({ user }) => { console.log('UserCard rendered for:', user.name); return ( <div> <h3>{user.name}</h3> <p>Age: {user.age}</p> </div> ); }); function UserProfile() { const [age, setAge] = useState(30); const [name, setName] = useState("Alice"); const [count, setCount] = useState(0); // 親の再レンダリングをトリガーする関係のない状態 // この `user` オブジェクトは、メモ化されない場合、レンダリングごとに再作成されます。 // useMemoを使用すると、`name`または`age`が変更された場合にのみ変更されます。 const user = useMemo(() => ({ name, age }), [name, age]); return ( <div> <UserCard user={user} /> <button onClick={() => setAge(age + 1)}>Increase Age</button> <button onClick={() => setCount(count + 1)}>Update Unrelated State ({count})</button> </div> ); }
「Update Unrelated State」がクリックされ、UserProfileが再レンダリングされると、「UserCard rendered for: Alice」はログされません。これは、useMemoのおかげでuserオブジェクトの参照が同じままであり、UserCardがメモ化されているためです。
高価な計算の回避
計算負荷の高いタスクを実行する関数またはコードブロックがあり、その結果が特定の値のみに依存する場合、useMemoは、この作業がレンダリングごとに不必要に繰り返されるのを防ぐことができます。
import React, { useState, useMemo } from 'react'; function ItemList({ items, filterText }) { const [sortOrder, setSortOrder] = useState('asc'); // このフィルタリングとソート操作は、`items`が大きい場合、高価になる可能性があります。 // `items`、`filterText`、または`sortOrder`が変更された場合にのみ、これを再実行したいと考えます。 const filteredAndSortedItems = useMemo(() => { console.log('Re-calculating filtered and sorted items...'); let result = items; if (filterText) { result = result.filter(item => item.name.includes(filterText)); } if (sortOrder === 'asc') { result.sort((a, b) => a.name.localeCompare(b.name)); } else { result.sort((a, b) => b.name.localeCompare(a.name)); } return result; }, [items, filterText, sortOrder]); return ( <div> <input type="text" value={filterText} /* onChange handler */ /> <button onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}> Sort: {sortOrder} </button> <ul> {filteredAndSortedItems.map(item => ( <li key={item.id}>{item.name}</li> ))} </ul> </div> ); }
このシナリオでは、useMemoは、フィルタリングとソートのロジックが、ItemListのすべての再レンダリング時ではなく、関連する依存関係が変更された場合にのみ実行されることを保証します。
エフェクトの最適化
useEffectを扱う場合、レンダリングごとに参照が変わる関数やオブジェクトを渡すと、エフェクトが不要に再実行され、パフォーマンスの問題やバグ(例:データの再取得)につながる可能性があります。useCallbackまたはuseMemoは、これらの依存関係を安定させることができます。
import React, { useState, useEffect, useCallback } from 'react'; function DataFetcher({ userId }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); // データを取得する関数。メモ化されない場合、レンダリングごとに新しい関数インスタンスになり、 // userIdが変わっていなくてもuseEffectが再実行される原因となります。 const fetchData = useCallback(async () => { setLoading(true); try { const response = await fetch(`/api/users/${userId}`); const result = await response.json(); setData(result); } catch (error) { console.error("Error fetching data:", error); } finally { setLoading(false); } }, [userId]); // fetchDataはuserIdが変わった場合にのみ変更されます useEffect(() => { fetchData(); }, [fetchData]); // エフェクトはfetchData(したがってuserId)が変わった場合にのみ再実行されます if (loading) return <div>Loading data...</div>; if (!data) return <div>No data found.</div>; return <div>User Name: {data.name}</div>; }
ここでは、fetchDataがuseCallbackでラップされています。これにより、useEffectフックがuserId(その依存関係)が実際に変更された場合にのみ再実行されることが保証され、冗長なAPI呼び出しが防止されます。
使用しない場合(または効果がない場合)
useMemoとuseCallbackが効果がない、または有害ですらある状況を理解することも同様に重要です。
-
単純な計算: 些細な計算や単純な関数定義の場合、
useMemo/useCallbackのオーバーヘッド(メモ化キャッシュの作成、依存関係の比較)は、潜在的なパフォーマンス上の利点を上回る可能性があります。// 本当の利点はない、オーバーヘッドが増えるだけ const sum = useMemo(() => a + b, [a, b]); -
React.memoでラップされていないコンポーネント: メモ化されたプロパティを受け取る子コンポーネント自体がメモ化されていない場合(React.memo経由またはPureComponentである場合)、プロパティの参照が変更されたかどうかに関係なく再レンダリングされます。この場合、useMemo/useCallbackは子コンポーネントの再レンダリングを防ぐ効果がありません。 -
状態更新のための空の依存配列を持つ
useCallback: 一般的な有用性にもかかわらず、状態セッター(setCountなど)のための空の依存配列を持つuseCallbackは誤解を招く可能性があります。Reactの状態セッター関数は安定していることが保証されており、再作成されることはありません。そのため、それらを空の依存配列でuseCallbackにラップすることは冗長です。// 冗長、setCountはすでに安定している const handleSetCount = useCallback(() => setCount(0), []);ただし、コールバックが現在の状態またはプロパティに依存する場合、その依存関係は正しく指定する必要があります。
-
過剰な使用:
useMemoとuseCallbackを過剰に使用すると、コードがより複雑になり、デバッグが困難になり、適切に適用しないと独自のパフォーマンスオーバーヘッドが発生する可能性があります。最適化する前に、常に測定してください。React DevToolsプロファイラーは、実際のパフォーマンスのボトルネックを特定するための優れたツールです。
結論
useMemoとuseCallbackは、Reactのパフォーマンス最適化の武器庫における強力なツールであり、主にメモ化と参照による等価性を活用して、不要な計算とメモ化されたコンポーネントの再レンダリングを防ぎます。それらは、、プロパティの参照(関数、オブジェクト)を安定させるため、または真に高価な計算の結果をキャッシュするために、メモ化された子コンポーネントに渡される場合に最も効果的です。しかし、それらは独自のオーバーヘッドをもたらし、普遍的な解決策としてではなく、特定されたパフォーマンスのボトルネックに焦点を当てて、思慮深く適用されるべきです。それらの根本的な原則と実用的なアプリケーションを理解することで、開発者はこれらのフックが真の価値を提供する場所に戦略的に展開することによって、より高速で効率的なReactアプリケーションを構築することができます。

