Skip to content

パフォーマンス最適化

注意: このドキュメントは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. 通信の効率化(データフェッチングライブラリ)”

問題: 従来のuseEffectfetchを使ったデータ取得では、以下の問題が発生します:

  • 同じデータを何度も取得してしまう(キャッシュがない)
  • 複数のコンポーネントで同じデータを取得する場合、重複リクエストが発生
  • ローディング状態やエラーハンドリングが煩雑
  • データが届くまでの速さが遅い

解決策: SWRやTanStack Queryなどのデータフェッチングライブラリを使用して、通信を最適化します。

3. 初期読み込みを軽くする(Code Splitting)

Section titled “3. 初期読み込みを軽くする(Code Splitting)”

問題: 大きなアプリを一度に読み込むと、最初の表示が遅くなります。

解決策: コード分割(Code Splitting)を使用して、必要な部分だけを読み込みます。


1. 無駄な再レンダリングを防ぐ(Memoization)

Section titled “1. 無駄な再レンダリングを防ぐ(Memoization)”

Reactは親が再レンダリングされると、子も自動的に再レンダリングされます。これを防ぐのがメモ化です。

❌ 問題のあるコード: 不要な再レンダリング

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>
);
}

問題点:

  1. 不要な再レンダリング: countが変わっても、ProductItemproductは変わっていないのに再レンダリングされる
  2. パフォーマンスの低下: 大量のProductItemがある場合、すべてが再レンダリングされると重くなる
  3. 計算の無駄: 再レンダリングのたびに、不要な計算が実行される

解決策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>
);
}

問題点(本当に重い計算の場合のみ):

  1. 重い計算の再実行: 数万件のデータで複雑なフィルタリング・ソート・集計を行う場合、毎回の計算が重くなる(数秒かかる可能性)
  2. パフォーマンスの低下: ユーザー操作のたびに計算が実行され、UIがフリーズする可能性がある
  3. ユーザー体験の低下: 計算中に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と組み合わせた場合のみ):

  1. React.memoが効かない: 子コンポーネントがReact.memoでメモ化されていても、props(関数)が毎回変わるため、再レンダリングされる
  2. 不要な再レンダリング: 親コンポーネントが再レンダリングされるたびに、子コンポーネントも再レンダリングされる

注意: 子コンポーネントが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} />;
}

ベストプラクティス:

  1. まずは素のままで書く: 本当に重い処理以外は、まずはメモ化せずに書く
  2. パフォーマンス問題が発生してから最適化: 実際にパフォーマンス問題が発生してから、メモ化を検討する
  3. 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>;
}

問題点:

  1. キャッシュがない: 同じデータを何度も取得してしまう
  2. 重複リクエスト: 複数のコンポーネントで同じデータを取得する場合、重複リクエストが発生
  3. ローディング状態の管理が煩雑: 各コンポーネントでローディング状態を管理する必要がある
  4. エラーハンドリングが不十分: エラーハンドリングのロジックが複雑
  5. データが届くまでの速さが遅い: キャッシュがないため、毎回サーバーにリクエストを送る

解決策: 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. 自動キャッシュ: 同じデータは再取得しない(キャッシュから取得)
  2. 重複リクエストの防止: 複数のコンポーネントで同じデータを取得する場合、1つのリクエストにまとめられる
  3. 自動再検証: ウィンドウフォーカス時や再接続時に自動でデータを再取得
  4. エラーハンドリングが簡単: エラーハンドリングが自動で行われる
  5. データが届くまでの速さが速い: キャッシュから即座にデータを表示し、バックグラウンドで再検証

実践例: グローバルな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のメリット:

  1. 豊富な機能: キャッシュ、再検証、エラーハンドリング、依存クエリなど
  2. 優秀なDevTools: キャッシュの状態を可視化できるDevTools
  3. 柔軟なキャッシュキー: 配列形式のキャッシュキーで、複雑な条件にも対応
  4. 大規模なコミュニティ: 大規模なコミュニティと豊富なドキュメント

実践例: 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. キャッシュがない: 同じデータを何度も取得してしまう
  2. 重複リクエスト: 複数のコンポーネントで同じデータを取得する場合、重複リクエストが発生
  3. ローディング状態の管理が煩雑: 各コンポーネントでローディング状態を管理する必要がある
  4. エラーハンドリングが不十分: エラーハンドリングのロジックが複雑
  5. データが届くまでの速さが遅い: キャッシュがないため、毎回サーバーにリクエストを送る

データフェッチングライブラリの解決:

  1. 自動キャッシュ: 同じデータは再取得しない(キャッシュから取得)
  2. 重複リクエストの防止: 複数のコンポーネントで同じデータを取得する場合、1つのリクエストにまとめられる
  3. 自動再検証: ウィンドウフォーカス時や再接続時に自動でデータを再取得
  4. エラーハンドリングが簡単: エラーハンドリングが自動で行われる
  5. データが届くまでの速さが速い: キャッシュから即座にデータを表示し、バックグラウンドで再検証

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. 初期読み込みが遅い: すべてのコンポーネントを一度に読み込むため、初期表示が遅くなる
  2. バンドルサイズが大きい: すべてのコードが1つのバンドルに含まれるため、バンドルサイズが大きくなる
  3. 不要なコードの読み込み: 使用しないコンポーネントも読み込まれる

解決策: コード分割(Code Splitting)

Section titled “解決策: コード分割(Code Splitting)”

**コード分割(Code Splitting)**は、必要な部分だけを読み込むための技術です。lazySuspenseを使用して、コンポーネントを遅延読み込みします。

✅ 解決されたコード: 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件だけ取得)
// - ネットワーク負荷が分散される
// - ユーザーが必要な分だけ取得できる

推奨アプローチ:

  1. まずAPIのパジネーションを検討: 1万件を一度に取得するのではなく、ページ単位で取得する
  2. 仮想リストと組み合わせ: パジネーションだけでは不十分な場合(例: 1ページあたり1000件)に仮想リストを使用
  3. 無限スクロールと組み合わせ: ユーザーがスクロールしたときに、次のページを自動的に取得する

解決策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>
);
}

問題点:

  1. ネットワークのオーバーヘッド: 小さなコンポーネントをlazyにすると、小さな通信がたくさん発生して、逆に遅くなる
  2. HTTPリクエストの増加: 各コンポーネントごとにHTTPリクエストが発生し、ネットワークのオーバーヘッドが増える
  3. パフォーマンスの低下: 小さなコンポーネントの読み込み待ち時間が、全体のパフォーマンスを低下させる

✅ 正しいコード: ページ単位または重いライブラリのみ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のメリット:

  1. 待ち時間の短縮: ユーザーがリンクをクリックする前に、すでに読み込みが始まっている
  2. ユーザー体験の向上: ページ遷移がスムーズになる
  3. ネットワークリソースの有効活用: ユーザーがリンクにマウスを乗せている間に、バックグラウンドで読み込みを開始

実践例: 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>
);
}

問題点:

  1. アプリのクラッシュ: ネットワークエラーが発生すると、アプリ全体がクラッシュする
  2. ユーザー体験の低下: エラーメッセージが表示されず、アプリが使用不能になる
  3. エラーハンドリングの欠如: エラーが発生した場合の適切な処理がない

✅ 解決されたコード: 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>
);
}

ベストプラクティス:

  1. Error BoundaryでSuspenseを囲む: lazyで読み込むコンポーネントは、必ずError Boundaryで囲む
  2. エラーログを送信: エラーが発生した場合、エラー報告サービス(Sentryなど)に送信する
  3. 再試行機能を提供: ユーザーが再試行できるボタンを提供する
  4. ユーザーフレンドリーなエラーメッセージ: 技術的なエラーメッセージではなく、ユーザーに分かりやすいメッセージを表示する

まとめ:

  • ページ単位または重いライブラリのみlazyにする: 小さなコンポーネントはlazyにしない
  • Preloadを活用: マウスがリンクに乗った瞬間に読み込みを開始
  • Error Boundaryでエラーハンドリング: ネットワークエラーが発生した場合の適切な処理

パフォーマンス最適化の全体像

Section titled “パフォーマンス最適化の全体像”
問題解決策ツール
不要な再レンダリングメモ化React.memo, useMemo, useCallback
重い計算の再実行計算結果のキャッシュuseMemo
関数の再生成関数のキャッシュuseCallback
データ取得の非効率データフェッチングライブラリSWR, TanStack Query
初期読み込みが遅いコード分割lazy, Suspense
  1. まずは素のままで書く: 本当に重い処理以外は、まずはメモ化せずに書く
  2. パフォーマンス問題が発生してから最適化: 実際にパフォーマンス問題が発生してから、最適化を検討する
  3. データフェッチングライブラリを使用: SWRやTanStack Queryを使用して、データ取得を最適化する
  4. コード分割を活用: 大きなアプリでは、コード分割を活用して初期読み込みを軽くする
  5. React Compiler導入後は不要: React Compiler(React Forget)が導入されると、手動でのメモ化が不要になる可能性がある
  • ⚠️ 過度なメモ化を避ける: メモ化自体にもコストがかかるため、本当に必要な場合のみ使用する
  • ⚠️ パフォーマンス問題が発生してから最適化: 最初から最適化するのではなく、問題が発生してから最適化する
  • ⚠️ データフェッチングライブラリは必須: 実務では、SWRやTanStack Queryなどのデータフェッチングライブラリを使用することが推奨される

パフォーマンス最適化は、「何が原因で重くなるのか」と「どう解決するのか」の対応関係を理解することが重要です。