パフォーマンス最適化
パフォーマンス最適化
Section titled “パフォーマンス最適化”注意: このドキュメントはTypeScript(TSX)前提で説明しています。現在のReact開発では、TypeScriptの採用が標準となっており、型安全性によるバグの早期発見、IDEの補完機能、リファクタリングの安全性などの理由から、JSXよりもTSXが選定されることが一般的です。
Reactのパフォーマンス最適化は、「何が原因で重くなるのか」と「どう解決するのか」の対応関係を理解することが重要です。初心者には最適化の手法が「呪文」のように見えてしまいますが、問題と解決策を明確にすることで、適切な最適化が可能になります。
パフォーマンス最適化の全体像
Section titled “パフォーマンス最適化の全体像”Reactの最適化は、大きく分けて**「計算・描画の節約」と「通信の効率化」**の2軸で考えます。
1. 計算・描画の節約(Memoization)
Section titled “1. 計算・描画の節約(Memoization)”問題: 親コンポーネントが再レンダリングされると、子コンポーネントも自動的に再レンダリングされます。これにより、不要な計算や描画が発生し、パフォーマンスが低下します。
解決策: メモ化(Memoization)を使用して、不要な再レンダリングや再計算を防ぎます。
- React.memo: コンポーネントの再レンダリングを防ぐ
- useMemo: 計算結果をキャッシュする
- useCallback: 関数をキャッシュする
2. 通信の効率化(データフェッチングライブラリ)
Section titled “2. 通信の効率化(データフェッチングライブラリ)”問題: 従来のuseEffectとfetchを使ったデータ取得では、以下の問題が発生します:
- 同じデータを何度も取得してしまう(キャッシュがない)
- 複数のコンポーネントで同じデータを取得する場合、重複リクエストが発生
- ローディング状態やエラーハンドリングが煩雑
- データが届くまでの速さが遅い
解決策: SWRやTanStack Queryなどのデータフェッチングライブラリを使用して、通信を最適化します。
3. 初期読み込みを軽くする(Code Splitting)
Section titled “3. 初期読み込みを軽くする(Code Splitting)”問題: 大きなアプリを一度に読み込むと、最初の表示が遅くなります。
解決策: コード分割(Code Splitting)を使用して、必要な部分だけを読み込みます。
1. 無駄な再レンダリングを防ぐ(Memoization)
Section titled “1. 無駄な再レンダリングを防ぐ(Memoization)”Reactは親が再レンダリングされると、子も自動的に再レンダリングされます。これを防ぐのがメモ化です。
問題: 不要な再レンダリング
Section titled “問題: 不要な再レンダリング”❌ 問題のあるコード: 不要な再レンダリング
type Product = { id: number; name: string; price: number;};
// 親コンポーネントfunction ProductList({ products }: { products: Product[] }) { const [count, setCount] = useState<number>(0);
return ( <div> <button onClick={() => setCount(count + 1)}>Count: {count}</button> {products.map(product => ( <ProductItem key={product.id} product={product} /> {/* 問題: countが変わるたびに、すべてのProductItemが再レンダリングされる */} ))} </div> );}
// 子コンポーネントfunction ProductItem({ product }: { product: Product }) { console.log('ProductItem rendered:', product.name); // 問題: countが変わるたびに、このコンポーネントも再レンダリングされる // しかし、productは変わっていないので、再レンダリングは不要
return ( <div> <h3>{product.name}</h3> <p>{product.price}円</p> </div> );}問題点:
- 不要な再レンダリング:
countが変わっても、ProductItemのproductは変わっていないのに再レンダリングされる - パフォーマンスの低下: 大量の
ProductItemがある場合、すべてが再レンダリングされると重くなる - 計算の無駄: 再レンダリングのたびに、不要な計算が実行される
解決策1: React.memo - 「見た目(コンポーネント)」のキャッシュ
Section titled “解決策1: React.memo - 「見た目(コンポーネント)」のキャッシュ”React.memoは、コンポーネントの再レンダリングを防ぐための高階コンポーネントです。Propsが変わらない限り、再描画しません。
✅ 解決されたコード: React.memoを使用
import { memo } from 'react';
// React.memoでコンポーネントをメモ化const ProductItem = memo(function ProductItem({ product }: { product: Product }) { console.log('ProductItem rendered:', product.name); // 解決: productが変わらない限り、再レンダリングされない
return ( <div> <h3>{product.name}</h3> <p>{product.price}円</p> </div> );});
// 親コンポーネントfunction ProductList({ products }: { products: Product[] }) { const [count, setCount] = useState<number>(0);
return ( <div> <button onClick={() => setCount(count + 1)}>Count: {count}</button> {products.map(product => ( <ProductItem key={product.id} product={product} /> {/* 解決: countが変わっても、productが変わらない限り再レンダリングされない */} ))} </div> );}React.memoの仕組み:
- Propsの比較: 前回のレンダリングと現在のレンダリングで、propsが同じかどうかを比較
- 再レンダリングのスキップ: propsが同じ場合、再レンダリングをスキップ
- パフォーマンスの向上: 不要な再レンダリングを防ぐことで、パフォーマンスが向上
実践例: カスタム比較関数を使用
// カスタム比較関数を使用して、特定の条件でのみ再レンダリングconst ProductItem = memo( function ProductItem({ product, isSelected }: { product: Product; isSelected: boolean; }) { return ( <div className={isSelected ? 'selected' : ''}> <h3>{product.name}</h3> <p>{product.price}円</p> </div> ); }, (prevProps, nextProps) => { // カスタム比較: idとisSelectedが同じなら再レンダリングをスキップ return ( prevProps.product.id === nextProps.product.id && prevProps.isSelected === nextProps.isSelected ); });解決策2: useMemo - 「計算結果」のキャッシュ
Section titled “解決策2: useMemo - 「計算結果」のキャッシュ”useMemoは、計算結果をキャッシュするためのフックです。重い計算(フィルタリングや合計など)を使い回すことができます。
【まさかり】「不要な再計算」について: 現代のReactにおいて、「filter関数を毎回回すこと」や「関数の再生成」自体が事故の原因になることは稀です。これらの記述は、**本当に重い計算(数万件のデータの複雑なフィルタリングなど)**がある場合にのみ問題になります。通常のケースでは、まずは素のままで書いて、実際にパフォーマンス問題が発生してから最適化を検討しましょう。
✅ 通常のケース: 素のままで問題ない
function ProductList({ products }: { products: Product[] }) { const [filter, setFilter] = useState<string>('all');
// 通常のケース: 数百〜数千件程度なら、useMemoなしでも問題ない const filteredProducts = products.filter(product => { if (filter === 'all') return true; if (filter === 'inStock') return product.inStock; return !product.inStock; });
const totalPrice = filteredProducts.reduce((sum, product) => sum + product.price, 0);
return ( <div> <FilterButtons filter={filter} onFilterChange={setFilter} /> <p>Total: {totalPrice}円</p> {filteredProducts.map(product => ( <ProductItem key={product.id} product={product} /> ))} </div> );}❌ 問題のあるコード: 本当に重い計算の場合のみ
function ProductList({ products }: { products: Product[] }) { const [filter, setFilter] = useState<string>('all');
// 問題: 数万件のデータで複雑なフィルタリング・ソート・集計を行う場合 // このケースでは、毎回の計算が重くなる(数秒かかる可能性) const filteredProducts = products .filter(product => { // 複雑なフィルタリングロジック if (filter === 'all') return true; if (filter === 'inStock') return product.inStock; return !product.inStock; }) .sort((a, b) => { // 複雑なソートロジック return a.price - b.price; });
// 問題: 複雑な集計計算 const totalPrice = filteredProducts.reduce((sum, product) => { // 複雑な計算ロジック return sum + product.price * product.tax; }, 0);
return ( <div> <FilterButtons filter={filter} onFilterChange={setFilter} /> <p>Total: {totalPrice}円</p> {filteredProducts.map(product => ( <ProductItem key={product.id} product={product} /> ))} </div> );}問題点(本当に重い計算の場合のみ):
- 重い計算の再実行: 数万件のデータで複雑なフィルタリング・ソート・集計を行う場合、毎回の計算が重くなる(数秒かかる可能性)
- パフォーマンスの低下: ユーザー操作のたびに計算が実行され、UIがフリーズする可能性がある
- ユーザー体験の低下: 計算中にUIが応答しなくなる
✅ 解決されたコード: useMemoを使用
import { useMemo } from 'react';
function ProductList({ products }: { products: Product[] }) { const [filter, setFilter] = useState<string>('all');
// 解決: filterやproductsが変わった場合のみ再計算 const filteredProducts = useMemo(() => { return products.filter(product => { if (filter === 'all') return true; if (filter === 'inStock') return product.inStock; return !product.inStock; }); }, [products, filter]); // 依存配列: productsとfilterが変わった場合のみ再計算
// 解決: filteredProductsが変わった場合のみ再計算 const totalPrice = useMemo(() => { return filteredProducts.reduce((sum, product) => sum + product.price, 0); }, [filteredProducts]);
return ( <div> <FilterButtons filter={filter} onFilterChange={setFilter} /> <p>Total: {totalPrice}円</p> {filteredProducts.map(product => ( <ProductItem key={product.id} product={product} /> ))} </div> );}useMemoの仕組み:
- 計算結果のキャッシュ: 依存配列の値が変わらない限り、前回の計算結果を返す
- 再計算のスキップ: 依存配列の値が同じ場合、再計算をスキップ
- パフォーマンスの向上: 重い計算をキャッシュすることで、パフォーマンスが向上
解決策3: useCallback - 「関数」のキャッシュ
Section titled “解決策3: useCallback - 「関数」のキャッシュ”【まさかり】関数の再生成について: 現代のReactにおいて、「関数の再生成」自体が事故の原因になることは稀です。
useCallbackが必要になるのは、子コンポーネントがReact.memoでメモ化されている場合に、propsとして関数を渡す場合のみです。通常のケースでは、まずは素のままで書いて、実際にパフォーマンス問題が発生してから最適化を検討しましょう。
useCallbackは、関数をキャッシュするためのフックです。関数を子に渡す際、新しく生成されるのを防ぎます。
❌ 問題のあるコード: React.memoと組み合わせた場合の問題
function ProductList({ products }: { products: Product[] }) { const [selectedId, setSelectedId] = useState<number | null>(null);
// 問題: 毎回新しい関数が作成される const handleClick = (product: Product) => { setSelectedId(product.id); };
return ( <div> {products.map(product => ( <ProductItem key={product.id} product={product} onClick={handleClick} // 問題: 毎回新しい関数が渡される /> ))} </div> );}
// 子コンポーネントがReact.memoでメモ化されている場合const ProductItem = memo(function ProductItem({ product, onClick}: { product: Product; onClick: (product: Product) => void;}) { // 問題: onClickが毎回新しい関数なので、React.memoが効かない return ( <div onClick={() => onClick(product)}> <h3>{product.name}</h3> </div> );});問題点(React.memoと組み合わせた場合のみ):
- React.memoが効かない: 子コンポーネントが
React.memoでメモ化されていても、props(関数)が毎回変わるため、再レンダリングされる - 不要な再レンダリング: 親コンポーネントが再レンダリングされるたびに、子コンポーネントも再レンダリングされる
注意: 子コンポーネントがReact.memoでメモ化されていない場合、この問題は発生しません。通常のケースでは、まずはReact.memoを使わずに書いて、実際にパフォーマンス問題が発生してから最適化を検討しましょう。
✅ 解決されたコード: useCallbackを使用
import { useCallback } from 'react';
function ProductList({ products }: { products: Product[] }) { const [selectedId, setSelectedId] = useState<number | null>(null);
// 解決: 依存配列が変わらない限り、同じ関数を返す const handleClick = useCallback((product: Product) => { setSelectedId(product.id); }, []); // 依存配列が空なので、関数は1回だけ作成される
return ( <div> {products.map(product => ( <ProductItem key={product.id} product={product} onClick={handleClick} // 解決: 同じ関数が渡される /> ))} </div> );}
// 子コンポーネントがReact.memoでメモ化されている場合const ProductItem = memo(function ProductItem({ product, onClick}: { product: Product; onClick: (product: Product) => void;}) { // 解決: onClickが同じ関数なので、React.memoが効く return ( <div onClick={() => onClick(product)}> <h3>{product.name}</h3> </div> );});useCallbackの仕組み:
- 関数のキャッシュ: 依存配列の値が変わらない限り、前回の関数を返す
- React.memoとの組み合わせ: 子コンポーネントが
React.memoでメモ化されている場合、効果を発揮 - パフォーマンスの向上: 不要な再レンダリングを防ぐことで、パフォーマンスが向上
【まさかり】過度なメモ化を避ける
Section titled “【まさかり】過度なメモ化を避ける”初心者はすべてをメモ化しがちですが、メモ化自体にもコストがかかります。
❌ 問題のあるコード: 過度なメモ化
// 問題: 単純な計算をメモ化する必要はないfunction Component({ count }: { count: number }) { const doubled = useMemo(() => count * 2, [count]); // メモ化不要 return <div>{doubled}</div>;}
// 問題: 単純な関数をメモ化する必要はないfunction Component() { const handleClick = useCallback(() => { console.log('Clicked'); }, []); // メモ化不要(子コンポーネントがメモ化されていない場合)
return <button onClick={handleClick}>Click</button>;}✅ 正しいコード: 本当に重い処理のみメモ化
// 正しい: 重い計算のみメモ化function Component({ items }: { items: Item[] }) { // 重い計算(フィルタリング + ソート + 合計)のみメモ化 const expensiveValue = useMemo(() => { return items .filter(item => item.active) .sort((a, b) => a.price - b.price) .reduce((sum, item) => sum + item.price, 0); }, [items]);
return <div>{expensiveValue}</div>;}
// 正しい: React.memoと組み合わせて使用const Child = memo(function Child({ onClick }: { onClick: () => void }) { return <button onClick={onClick}>Click</button>;});
function Parent() { const handleClick = useCallback(() => { console.log('Clicked'); }, []); // React.memoと組み合わせる場合のみ効果がある
return <Child onClick={handleClick} />;}ベストプラクティス:
- まずは素のままで書く: 本当に重い処理以外は、まずはメモ化せずに書く
- パフォーマンス問題が発生してから最適化: 実際にパフォーマンス問題が発生してから、メモ化を検討する
- React Compiler導入後は不要: React Compiler(React Forget)が導入されると、手動でのメモ化が不要になる可能性がある
2. 通信を最適化する(SWR / TanStack Query)
Section titled “2. 通信を最適化する(SWR / TanStack Query)”「パフォーマンス」には、画面のサクサク感だけでなく、**「データが届くまでの速さ」**も含まれます。ここでSWRやTanStack Queryなどのデータフェッチングライブラリが登場します。
問題: 従来のデータフェッチングの問題
Section titled “問題: 従来のデータフェッチングの問題”❌ 問題のあるコード: useEffectとfetchを使用
function UserProfile({ userId }: { userId: string }) { const [user, setUser] = useState<User | null>(null); const [isLoading, setIsLoading] = useState<boolean>(true); const [error, setError] = useState<Error | null>(null);
useEffect(() => { setIsLoading(true); setError(null);
fetch(`/api/users/${userId}`) .then(response => response.json()) .then(data => { setUser(data); setIsLoading(false); }) .catch(err => { setError(err); setIsLoading(false); }); }, [userId]);
if (isLoading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return <div>{user?.name}</div>;}問題点:
- キャッシュがない: 同じデータを何度も取得してしまう
- 重複リクエスト: 複数のコンポーネントで同じデータを取得する場合、重複リクエストが発生
- ローディング状態の管理が煩雑: 各コンポーネントでローディング状態を管理する必要がある
- エラーハンドリングが不十分: エラーハンドリングのロジックが複雑
- データが届くまでの速さが遅い: キャッシュがないため、毎回サーバーにリクエストを送る
解決策: SWRを使用したデータフェッチング
Section titled “解決策: SWRを使用したデータフェッチング”**SWR(stale-while-revalidate)**は、データフェッチングのためのReact Hooksライブラリです。自動キャッシュ、再検証、エラーハンドリングなどの機能を提供します。
✅ 解決されたコード: SWRを使用
import useSWR from 'swr';
// fetcher関数: データを取得する関数const fetcher = async (url: string) => { const response = await fetch(url); if (!response.ok) { throw new Error('Failed to fetch'); } return response.json();};
function UserProfile({ userId }: { userId: string }) { // SWRを使用: 自動キャッシュ、再検証、エラーハンドリング const { data: user, error, isLoading } = useSWR( `/api/users/${userId}`, // キャッシュキー fetcher, // データ取得関数 { revalidateOnFocus: true, // ウィンドウフォーカス時に再検証 revalidateOnReconnect: true, // 再接続時に再検証 dedupingInterval: 2000, // 2秒間は重複リクエストを防ぐ } );
if (isLoading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return <div>{user?.name}</div>;}SWRのメリット:
- 自動キャッシュ: 同じデータは再取得しない(キャッシュから取得)
- 重複リクエストの防止: 複数のコンポーネントで同じデータを取得する場合、1つのリクエストにまとめられる
- 自動再検証: ウィンドウフォーカス時や再接続時に自動でデータを再取得
- エラーハンドリングが簡単: エラーハンドリングが自動で行われる
- データが届くまでの速さが速い: キャッシュから即座にデータを表示し、バックグラウンドで再検証
実践例: グローバルなSWR設定
import { SWRConfig } from 'swr';
function App() { return ( <SWRConfig value={{ fetcher: async (url: string) => { const response = await fetch(url); if (!response.ok) { throw new Error('Failed to fetch'); } return response.json(); }, revalidateOnFocus: true, revalidateOnReconnect: true, dedupingInterval: 2000, }} > <UserProfile userId="1" /> <UserProfile userId="2" /> </SWRConfig> );}解決策: TanStack Queryを使用したデータフェッチング
Section titled “解決策: TanStack Queryを使用したデータフェッチング”**TanStack Query(旧React Query)**は、SWRと同様の機能を提供するデータフェッチングライブラリです。より豊富な機能とDevToolsを提供します。
✅ 解決されたコード: TanStack Queryを使用
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: string }) { // TanStack Queryを使用 const { data: user, error, isLoading } = useQuery({ queryKey: ['user', userId], // キャッシュキー(配列形式) queryFn: async () => { const response = await fetch(`/api/users/${userId}`); if (!response.ok) { throw new Error('Failed to fetch'); } return response.json(); }, staleTime: 5 * 60 * 1000, // 5分間は新鮮とみなす cacheTime: 10 * 60 * 1000, // 10分間キャッシュを保持 });
if (isLoading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return <div>{user?.name}</div>;}TanStack Queryのメリット:
- 豊富な機能: キャッシュ、再検証、エラーハンドリング、依存クエリなど
- 優秀なDevTools: キャッシュの状態を可視化できるDevTools
- 柔軟なキャッシュキー: 配列形式のキャッシュキーで、複雑な条件にも対応
- 大規模なコミュニティ: 大規模なコミュニティと豊富なドキュメント
実践例: TanStack Queryの設定
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60 * 1000, // 5分間は新鮮とみなす cacheTime: 10 * 60 * 1000, // 10分間キャッシュを保持 refetchOnWindowFocus: true, // ウィンドウフォーカス時に再取得 }, },});
function App() { return ( <QueryClientProvider client={queryClient}> <UserProfile userId="1" /> <UserProfile userId="2" /> </QueryClientProvider> );}なぜデータフェッチングライブラリが必要なのか
Section titled “なぜデータフェッチングライブラリが必要なのか”従来の方法の問題:
- キャッシュがない: 同じデータを何度も取得してしまう
- 重複リクエスト: 複数のコンポーネントで同じデータを取得する場合、重複リクエストが発生
- ローディング状態の管理が煩雑: 各コンポーネントでローディング状態を管理する必要がある
- エラーハンドリングが不十分: エラーハンドリングのロジックが複雑
- データが届くまでの速さが遅い: キャッシュがないため、毎回サーバーにリクエストを送る
データフェッチングライブラリの解決:
- 自動キャッシュ: 同じデータは再取得しない(キャッシュから取得)
- 重複リクエストの防止: 複数のコンポーネントで同じデータを取得する場合、1つのリクエストにまとめられる
- 自動再検証: ウィンドウフォーカス時や再接続時に自動でデータを再取得
- エラーハンドリングが簡単: エラーハンドリングが自動で行われる
- データが届くまでの速さが速い: キャッシュから即座にデータを表示し、バックグラウンドで再検証
3. 初期読み込みを軽くする(Code Splitting)
Section titled “3. 初期読み込みを軽くする(Code Splitting)”大きなアプリを一度に読み込むと、最初の表示が遅くなります。コード分割(Code Splitting)を使用して、必要な部分だけを読み込みます。
問題: 大きなアプリの初期読み込みが遅い
Section titled “問題: 大きなアプリの初期読み込みが遅い”❌ 問題のあるコード: すべてを一度に読み込む
import { HeavyComponent } from './HeavyComponent';import { AnotherHeavyComponent } from './AnotherHeavyComponent';
function App() { return ( <div> <HeavyComponent /> {/* 問題: 初期表示時に読み込まれる */} <AnotherHeavyComponent /> {/* 問題: 初期表示時に読み込まれる */} </div> );}問題点:
- 初期読み込みが遅い: すべてのコンポーネントを一度に読み込むため、初期表示が遅くなる
- バンドルサイズが大きい: すべてのコードが1つのバンドルに含まれるため、バンドルサイズが大きくなる
- 不要なコードの読み込み: 使用しないコンポーネントも読み込まれる
解決策: コード分割(Code Splitting)
Section titled “解決策: コード分割(Code Splitting)”**コード分割(Code Splitting)**は、必要な部分だけを読み込むための技術です。lazyとSuspenseを使用して、コンポーネントを遅延読み込みします。
✅ 解決されたコード: lazyとSuspenseを使用
import { lazy, Suspense } from 'react';
// lazy: コンポーネントを遅延読み込みconst HeavyComponent = lazy(() => import('./HeavyComponent'));const AnotherHeavyComponent = lazy(() => import('./AnotherHeavyComponent'));
function App() { return ( <div> <Suspense fallback={<div>Loading...</div>}> <HeavyComponent /> {/* 解決: 必要な時だけ読み込まれる */} </Suspense> <Suspense fallback={<div>Loading...</div>}> <AnotherHeavyComponent /> {/* 解決: 必要な時だけ読み込まれる */} </Suspense> </div> );}コード分割の仕組み:
- lazy: コンポーネントを動的にインポートする(必要な時だけ読み込む)
- Suspense: コンポーネントの読み込み中に表示するフォールバックを提供
- バンドルサイズの削減: 必要な部分だけを読み込むため、バンドルサイズが削減される
実践例: ルーティングでのコード分割
import { lazy, Suspense } from 'react';import { BrowserRouter, Routes, Route } from 'react-router-dom';
// 各ページを遅延読み込みconst HomePage = lazy(() => import('./pages/HomePage'));const AboutPage = lazy(() => import('./pages/AboutPage'));const ContactPage = lazy(() => import('./pages/ContactPage'));
function App() { return ( <BrowserRouter> <Suspense fallback={<div>Loading...</div>}> <Routes> <Route path="/" element={<HomePage />} /> <Route path="/about" element={<AboutPage />} /> <Route path="/contact" element={<ContactPage />} /> </Routes> </Suspense> </BrowserRouter> );}4. 大量のDOMノードを避ける(ウィンドウイング / 仮想リスト)
Section titled “4. 大量のDOMノードを避ける(ウィンドウイング / 仮想リスト)”【まさかり】根本解決への言及: 仮想スクロール(Virtualization)があるのは素晴らしいですが、そもそも**「1万件のデータを一度にフロントに持ってくること」自体がメモリを圧迫します**。「フロントでの仮想化」だけでなく、「APIのパジネーション(分割取得)」をまず検討すべきです。
10万ノードのDOMを避けるために、**「ウィンドウイング(Windowing)」または「仮想リスト(Virtualization)」**を使用します。画面に見えている分だけをDOM化する技術です。
問題: 大量のDOMノードによるパフォーマンス低下
Section titled “問題: 大量のDOMノードによるパフォーマンス低下”❌ 問題のあるコード: 全データを一度に取得してDOM化
function ProductList() { const [products, setProducts] = useState<Product[]>([]);
useEffect(() => { // 問題: 1万件のデータを一度に取得 fetch('/api/products') .then(res => res.json()) .then(data => setProducts(data)); }, []);
// 問題: 10,000件のデータをすべてDOM化すると、10,000個のDOMノードが作成される return ( <div> {products.map(product => ( <div key={product.id}> <h3>{product.name}</h3> <p>{product.price}円</p> </div> ))} </div> );}
// 問題点:// - 1万件のデータを一度に取得すると、メモリを圧迫する(100MB以上)// - 10,000件のデータで10,000個のDOMノードが作成される// - レンダリングが重くなる(数秒かかる)// - スクロールがカクつく解決策1(根本解決): APIのパジネーション(分割取得)
Section titled “解決策1(根本解決): APIのパジネーション(分割取得)”✅ 根本解決: APIでパジネーションを実装
function ProductList() { const [products, setProducts] = useState<Product[]>([]); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true);
useEffect(() => { // 解決: ページ単位でデータを取得(例: 1ページあたり20件) fetch(`/api/products?page=${page}&limit=20`) .then(res => res.json()) .then(data => { if (data.products.length === 0) { setHasMore(false); } else { setProducts(prev => [...prev, ...data.products]); } }); }, [page]);
return ( <div> {products.map(product => ( <div key={product.id}> <h3>{product.name}</h3> <p>{product.price}円</p> </div> ))} {hasMore && ( <button onClick={() => setPage(prev => prev + 1)}> もっと見る </button> )} </div> );}
// 利点:// - メモリ使用量が削減される(20件ずつしか保持しない)// - 初期表示が速い(最初の20件だけ取得)// - ネットワーク負荷が分散される// - ユーザーが必要な分だけ取得できる推奨アプローチ:
- まずAPIのパジネーションを検討: 1万件を一度に取得するのではなく、ページ単位で取得する
- 仮想リストと組み合わせ: パジネーションだけでは不十分な場合(例: 1ページあたり1000件)に仮想リストを使用
- 無限スクロールと組み合わせ: ユーザーがスクロールしたときに、次のページを自動的に取得する
解決策2: 仮想リスト(@tanstack/react-virtual)
Section titled “解決策2: 仮想リスト(@tanstack/react-virtual)”注意: 仮想リストは、パジネーションだけでは不十分な場合(例: 1ページあたり1000件のデータを表示する必要がある場合)に使用します。
✅ 解決されたコード: 仮想リストを使用
import { useVirtualizer } from '@tanstack/react-virtual';import { useRef } from 'react';
function VirtualProductList({ products }: { products: Product[] }) { const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({ count: products.length, getScrollElement: () => parentRef.current, estimateSize: () => 80, // 各アイテムの高さ(推定) overscan: 5, // 表示範囲の前後に5個ずつ余分にレンダリング(スクロール時のちらつき防止) });
return ( <div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}> <div style={{ height: `${virtualizer.getTotalSize()}px`, width: '100%', position: 'relative', }} > {virtualizer.getVirtualItems().map(virtualItem => ( <div key={virtualItem.key} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: `${virtualItem.size}px`, transform: `translateY(${virtualItem.start}px)`, }} > <h3>{products[virtualItem.index].name}</h3> <p>{products[virtualItem.index].price}円</p> </div> ))} </div> </div> );}
// 利点:// - 表示範囲のアイテムのみをDOM化(通常10〜20個程度)// - 10,000件のデータでもDOMノード数は10〜20個に制限される// - レンダリングが高速(数ミリ秒)// - メモリ使用量が削減(数MB)// - スクロールがスムーズ推奨ライブラリ:
@tanstack/react-virtual: モダンで柔軟な仮想リストライブラリ(推奨)react-window: シンプルで軽量な仮想リストライブラリ
実践例: react-window
import { FixedSizeList } from 'react-window';
function ProductList({ products }: { products: Product[] }) { const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => ( <div style={style}> <h3>{products[index].name}</h3> <p>{products[index].price}円</p> </div> );
return ( <FixedSizeList height={500} itemCount={products.length} itemSize={80} width="100%" > {Row} </FixedSizeList> );}使い分け:
- 通常のリスト(100件以下): 仮想リストは不要(オーバーヘッドの方が大きい)
- 中規模のリスト(100〜1,000件): 必要に応じて仮想リストを使用
- 大規模のリスト(1,000件以上): 仮想リストを必須で使用
5. さらに一歩進んだ「まさかり」知識
Section titled “5. さらに一歩進んだ「まさかり」知識”実務レベルでは、以下のことにも注意します。
ネットワークのオーバーヘッド: 細かくlazyにしすぎると逆に遅くなる
Section titled “ネットワークのオーバーヘッド: 細かくlazyにしすぎると逆に遅くなる”❌ 問題のあるコード: 細かくlazyにしすぎる
import { lazy, Suspense } from 'react';
// 問題: 小さなボタン一つ一つまでlazyにしているconst Button = lazy(() => import('./components/Button'));const SmallIcon = lazy(() => import('./components/SmallIcon'));const Tooltip = lazy(() => import('./components/Tooltip'));
function App() { return ( <div> <Suspense fallback={<div>Loading...</div>}> <Button /> {/* 問題: 小さなコンポーネントをlazyにすると、逆に遅くなる */} </Suspense> <Suspense fallback={<div>Loading...</div>}> <SmallIcon /> {/* 問題: 小さなコンポーネントをlazyにすると、逆に遅くなる */} </Suspense> <Suspense fallback={<div>Loading...</div>}> <Tooltip /> {/* 問題: 小さなコンポーネントをlazyにすると、逆に遅くなる */} </Suspense> </div> );}問題点:
- ネットワークのオーバーヘッド: 小さなコンポーネントをlazyにすると、小さな通信がたくさん発生して、逆に遅くなる
- HTTPリクエストの増加: 各コンポーネントごとにHTTPリクエストが発生し、ネットワークのオーバーヘッドが増える
- パフォーマンスの低下: 小さなコンポーネントの読み込み待ち時間が、全体のパフォーマンスを低下させる
✅ 正しいコード: ページ単位または重いライブラリのみlazyにする
import { lazy, Suspense } from 'react';
// 正しい: ページ単位でlazyにするconst DashboardPage = lazy(() => import('./pages/DashboardPage'));const SettingsPage = lazy(() => import('./pages/SettingsPage'));
// 正しい: 明らかに重いライブラリを含むコンポーネントのみlazyにするconst ChartComponent = lazy(() => import('./components/ChartComponent')); // チャートライブラリを含むconst EditorComponent = lazy(() => import('./components/EditorComponent')); // エディタライブラリを含む
// 正しい: 小さなコンポーネントは通常のインポートimport { Button } from './components/Button';import { SmallIcon } from './components/SmallIcon';import { Tooltip } from './components/Tooltip';
function App() { return ( <div> {/* 小さなコンポーネントは通常のインポート */} <Button /> <SmallIcon /> <Tooltip />
{/* ページ単位または重いライブラリのみlazyにする */} <Suspense fallback={<div>Loading...</div>}> <DashboardPage /> </Suspense> </div> );}ベストプラクティス:
- ✅ ページ単位でlazyにする: ルーティングで使用するページコンポーネント
- ✅ 明らかに重いライブラリを含むもの: チャートライブラリ、エディタライブラリなど
- ❌ 小さなコンポーネントはlazyにしない: ボタン、アイコン、ツールチップなど
Preload(先読み): マウスがリンクに乗った瞬間に読み込みを開始
Section titled “Preload(先読み): マウスがリンクに乗った瞬間に読み込みを開始”問題: ユーザーがリンクをクリックしてから、ページの読み込みが始まるため、待ち時間が発生します。
解決策: マウスがリンクに乗った瞬間(onMouseEnter)に、裏で読み込みを開始します。
✅ 解決されたコード: Preloadを使用
import { lazy, Suspense, useState } from 'react';import { Link } from 'react-router-dom';
// 各ページを遅延読み込みconst DashboardPage = lazy(() => import('./pages/DashboardPage'));const SettingsPage = lazy(() => import('./pages/SettingsPage'));
// Preload関数: マウスがリンクに乗った瞬間に読み込みを開始function preloadPage(importFn: () => Promise<any>) { importFn();}
function Navigation() { return ( <nav> <Link to="/dashboard" onMouseEnter={() => preloadPage(() => import('./pages/DashboardPage'))} > Dashboard </Link> <Link to="/settings" onMouseEnter={() => preloadPage(() => import('./pages/SettingsPage'))} > Settings </Link> </nav> );}
function App() { return ( <BrowserRouter> <Navigation /> <Suspense fallback={<div>Loading...</div>}> <Routes> <Route path="/dashboard" element={<DashboardPage />} /> <Route path="/settings" element={<SettingsPage />} /> </Routes> </Suspense> </BrowserRouter> );}Preloadのメリット:
- 待ち時間の短縮: ユーザーがリンクをクリックする前に、すでに読み込みが始まっている
- ユーザー体験の向上: ページ遷移がスムーズになる
- ネットワークリソースの有効活用: ユーザーがリンクにマウスを乗せている間に、バックグラウンドで読み込みを開始
実践例: React Routerのpreload機能
import { lazy, Suspense } from 'react';import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
// 各ページを遅延読み込みconst DashboardPage = lazy(() => import('./pages/DashboardPage'));const SettingsPage = lazy(() => import('./pages/SettingsPage'));
// Preloadコンポーネント: マウスがリンクに乗った瞬間に読み込みを開始function PreloadLink({ to, children, ...props }: { to: string; children: React.ReactNode }) { const handleMouseEnter = () => { // ページのインポートを事前に開始 if (to === '/dashboard') { import('./pages/DashboardPage'); } else if (to === '/settings') { import('./pages/SettingsPage'); } };
return ( <Link to={to} onMouseEnter={handleMouseEnter} {...props}> {children} </Link> );}
function Navigation() { return ( <nav> <PreloadLink to="/dashboard">Dashboard</PreloadLink> <PreloadLink to="/settings">Settings</PreloadLink> </nav> );}
function App() { return ( <BrowserRouter> <Navigation /> <Suspense fallback={<div>Loading...</div>}> <Routes> <Route path="/dashboard" element={<DashboardPage />} /> <Route path="/settings" element={<SettingsPage />} /> </Routes> </Suspense> </BrowserRouter> );}エラー境界(Error Boundary): lazyでネットワークエラーが発生した場合の処理
Section titled “エラー境界(Error Boundary): lazyでネットワークエラーが発生した場合の処理”問題: lazyでコンポーネントを読み込む際、ネットワークエラーが発生すると、アプリがクラッシュしてしまいます。
❌ 問題のあるコード: エラーハンドリングがない
import { lazy, Suspense } from 'react';
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
function App() { return ( <Suspense fallback={<div>Loading...</div>}> <DashboardPage /> {/* 問題: ネットワークエラーが発生すると、アプリがクラッシュする */} </Suspense> );}問題点:
- アプリのクラッシュ: ネットワークエラーが発生すると、アプリ全体がクラッシュする
- ユーザー体験の低下: エラーメッセージが表示されず、アプリが使用不能になる
- エラーハンドリングの欠如: エラーが発生した場合の適切な処理がない
✅ 解決されたコード: Error Boundaryでエラーハンドリング
import { lazy, Suspense, Component, ReactNode } from 'react';
// Error Boundary: エラーをキャッチして、エラーフォールバックUIを表示type ErrorBoundaryProps = { children: ReactNode;};
type ErrorBoundaryState = { hasError: boolean; error: Error | null;};
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { constructor(props: ErrorBoundaryProps) { super(props); this.state = { hasError: false, error: null }; }
static getDerivedStateFromError(error: Error): ErrorBoundaryState { return { hasError: true, error }; }
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { // エラーログを送信(Sentryなど) console.error('Error caught by boundary:', error, errorInfo); // errorReportingService.captureException(error, { extra: errorInfo }); }
render() { if (this.state.hasError) { return ( <div className="error-boundary"> <h2>通信エラーが発生しました</h2> <p>ページの読み込みに失敗しました。ネットワーク接続を確認してください。</p> <button onClick={() => this.setState({ hasError: false, error: null })}> 再試行 </button> </div> ); }
return this.props.children; }}
// 各ページを遅延読み込みconst DashboardPage = lazy(() => import('./pages/DashboardPage'));const SettingsPage = lazy(() => import('./pages/SettingsPage'));
function App() { return ( <ErrorBoundary> {/* 解決: Error BoundaryでSuspenseを囲むことで、ネットワークエラーをキャッチ */} <Suspense fallback={<div>Loading...</div>}> <Routes> <Route path="/dashboard" element={<DashboardPage />} /> <Route path="/settings" element={<SettingsPage />} /> </Routes> </Suspense> </ErrorBoundary> );}Error Boundaryの仕組み:
- getDerivedStateFromError: エラーが発生した際に、stateを更新してエラー状態にする
- componentDidCatch: エラーの詳細情報をログに記録し、エラー報告サービスに送信
- エラーフォールバックUI: エラーが発生した場合、エラーメッセージと再試行ボタンを表示
実践例: 関数コンポーネントでのError Boundary(react-error-boundaryライブラリ)
import { lazy, Suspense } from 'react';import { ErrorBoundary } from 'react-error-boundary';
// エラーフォールバックコンポーネントfunction ErrorFallback({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void;}) { return ( <div className="error-boundary"> <h2>通信エラーが発生しました</h2> <p>ページの読み込みに失敗しました。ネットワーク接続を確認してください。</p> <p>エラー詳細: {error.message}</p> <button onClick={resetErrorBoundary}>再試行</button> </div> );}
// 各ページを遅延読み込みconst DashboardPage = lazy(() => import('./pages/DashboardPage'));const SettingsPage = lazy(() => import('./pages/SettingsPage'));
function App() { return ( <ErrorBoundary FallbackComponent={ErrorFallback} onError={(error, errorInfo) => { // エラーログを送信 console.error('Error caught by boundary:', error, errorInfo); // errorReportingService.captureException(error, { extra: errorInfo }); }} > <Suspense fallback={<div>Loading...</div>}> <Routes> <Route path="/dashboard" element={<DashboardPage />} /> <Route path="/settings" element={<SettingsPage />} /> </Routes> </Suspense> </ErrorBoundary> );}ベストプラクティス:
- Error BoundaryでSuspenseを囲む:
lazyで読み込むコンポーネントは、必ずError Boundaryで囲む - エラーログを送信: エラーが発生した場合、エラー報告サービス(Sentryなど)に送信する
- 再試行機能を提供: ユーザーが再試行できるボタンを提供する
- ユーザーフレンドリーなエラーメッセージ: 技術的なエラーメッセージではなく、ユーザーに分かりやすいメッセージを表示する
まとめ:
- ✅ ページ単位または重いライブラリのみlazyにする: 小さなコンポーネントはlazyにしない
- ✅ Preloadを活用: マウスがリンクに乗った瞬間に読み込みを開始
- ✅ Error Boundaryでエラーハンドリング: ネットワークエラーが発生した場合の適切な処理
パフォーマンス最適化の全体像
Section titled “パフォーマンス最適化の全体像”| 問題 | 解決策 | ツール |
|---|---|---|
| 不要な再レンダリング | メモ化 | React.memo, useMemo, useCallback |
| 重い計算の再実行 | 計算結果のキャッシュ | useMemo |
| 関数の再生成 | 関数のキャッシュ | useCallback |
| データ取得の非効率 | データフェッチングライブラリ | SWR, TanStack Query |
| 初期読み込みが遅い | コード分割 | lazy, Suspense |
ベストプラクティス
Section titled “ベストプラクティス”- まずは素のままで書く: 本当に重い処理以外は、まずはメモ化せずに書く
- パフォーマンス問題が発生してから最適化: 実際にパフォーマンス問題が発生してから、最適化を検討する
- データフェッチングライブラリを使用: SWRやTanStack Queryを使用して、データ取得を最適化する
- コード分割を活用: 大きなアプリでは、コード分割を活用して初期読み込みを軽くする
- React Compiler導入後は不要: React Compiler(React Forget)が導入されると、手動でのメモ化が不要になる可能性がある
- ⚠️ 過度なメモ化を避ける: メモ化自体にもコストがかかるため、本当に必要な場合のみ使用する
- ⚠️ パフォーマンス問題が発生してから最適化: 最初から最適化するのではなく、問題が発生してから最適化する
- ⚠️ データフェッチングライブラリは必須: 実務では、SWRやTanStack Queryなどのデータフェッチングライブラリを使用することが推奨される
パフォーマンス最適化は、「何が原因で重くなるのか」と「どう解決するのか」の対応関係を理解することが重要です。