Reactアプリが重い原因と対策|初心者でもできる最適化

Reactアプリケーションが重くなる原因を特定し、初心者でも実践できる具体的な最適化手法を解説。パフォーマンス改善の基本から実践まで詳しく説明します。

Learning Next 運営
77 分で読めます

みなさん、Reactアプリを開発していて、こんな悩みを抱えたことはありませんか?

「アプリの動作が重くて、ユーザーに迷惑をかけている」「画面の切り替えが遅くて、使いにくいと言われた」「どこから最適化すればいいかわからない」

そんな経験をしたことがあるのではないでしょうか。

実は、Reactアプリが重くなる原因には共通のパターンがあります。 適切な対策を講じることで、大幅な改善が可能なんです。

この記事では、Reactアプリが重くなる主な原因を特定し、初心者でも実践できる具体的な最適化手法を詳しく解説します。 パフォーマンス改善の基本から実践的なテクニックまで、実際のコード例を交えてわかりやすく説明しますよ。

一緒に、快適なReactアプリケーションを作っていきましょう!

Reactアプリが重くなる原因を知ろう

Reactアプリケーションのパフォーマンス問題を理解するために、まず主な原因を特定しましょう。

「なんで重いの?」と悩む前に、原因を知ることが大切です。

最も多い原因:不要な再レンダリング

最も一般的な問題は、コンポーネントの不要な再レンダリングです。

// 問題のあるコード例
function App() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');
  
  return (
    <div>
      <input 
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="名前を入力"
      />
      
      <button onClick={() => setCount(count + 1)}>
        Count: {count}
      </button>
      
      {/* countが変わるたびに、ExpensiveComponentも再レンダリング */}
      <ExpensiveComponent data={someData} />
    </div>
  );
}

function ExpensiveComponent({ data }) {
  // 重い計算処理
  const processedData = data.map(item => {
    // 複雑な処理...
    return expensiveCalculation(item);
  });
  
  return (
    <div>
      {processedData.map(item => (
        <div key={item.id}>{item.result}</div>
      ))}
    </div>
  );
}

この例では、countが変更されるたびにExpensiveComponentも再レンダリングされてしまいます。 「必要ないのに処理が実行される」のが問題なんですね。

大量のデータを一度に表示する問題

// 問題のあるコード例
function ProductList({ products }) {
  return (
    <div>
      {/* 1万件のデータを一度に描画 */}
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

function ProductCard({ product }) {
  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.description}</p>
      <span>{product.price}円</span>
    </div>
  );
}

大量のデータを一度に描画すると、初期レンダリングが非常に遅くなります。 「全部表示しようとして、結果的に何も表示されない時間が長い」状態になってしまいます。

重い計算を毎回実行する問題

// 問題のあるコード例
function Dashboard({ transactions }) {
  // 毎回レンダリング時に重い計算が実行される
  const totalAmount = transactions.reduce((sum, transaction) => {
    return sum + calculateTax(transaction.amount);
  }, 0);
  
  const averageAmount = totalAmount / transactions.length;
  const categorizedData = categorizeTransactions(transactions);
  
  return (
    <div>
      <h1>合計: {totalAmount}円</h1>
      <h2>平均: {averageAmount}円</h2>
      <TransactionChart data={categorizedData} />
    </div>
  );
}

「同じ計算を何度も繰り返している」のが無駄な処理の原因です。

メモリリークが発生する問題

// 問題のあるコード例
function Timer() {
  const [time, setTime] = useState(0);
  
  useEffect(() => {
    const interval = setInterval(() => {
      setTime(prev => prev + 1);
    }, 1000);
    
    // クリーンアップ関数がない!
    // コンポーネントがアンマウントされてもタイマーが残る
  }, []);
  
  return <div>Time: {time}</div>;
}

「使わなくなったリソースが残り続ける」のがメモリリークの問題です。

状態管理が非効率な問題

// 問題のあるコード例
function App() {
  const [userData, setUserData] = useState({
    profile: {},
    settings: {},
    notifications: [],
    friends: [],
    messages: []
  });
  
  // 通知を1つ追加するだけで、すべてのユーザーデータが更新される
  const addNotification = (notification) => {
    setUserData(prev => ({
      ...prev,
      notifications: [...prev.notifications, notification]
    }));
  };
  
  return (
    <div>
      {/* すべてのコンポーネントが再レンダリング */}
      <UserProfile data={userData.profile} />
      <UserSettings data={userData.settings} />
      <NotificationList data={userData.notifications} />
      <FriendsList data={userData.friends} />
      <MessagesList data={userData.messages} />
    </div>
  );
}

「小さな変更で大きな影響が出る」のが非効率な状態管理の問題です。

画像が最適化されていない問題

// 問題のあるコード例
function ImageGallery({ images }) {
  return (
    <div className="gallery">
      {images.map(image => (
        <img
          key={image.id}
          src={image.fullSizeUrl} // 高解像度画像を直接読み込み
          alt={image.title}
          style={{ width: '200px', height: '150px' }} // CSSでリサイズ
        />
      ))}
    </div>
  );
}

「大きな画像を小さく表示している」のは非効率ですね。

key属性が不適切な問題

// 問題のあるコード例
function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo, index) => (
        // インデックスをkeyに使用(問題あり)
        <li key={index}>
          <input type="checkbox" checked={todo.completed} />
          {todo.text}
        </li>
      ))}
    </ul>
  );
}

「Reactが効率的に更新できない状態」になってしまいます。

これらの原因を理解することで、適切な対策を講じることができるようになります。 次に、具体的な最適化手法を詳しく見ていきましょう。

基本的な最適化手法をマスターしよう

まずは初心者でも簡単に実践できる基本的な最適化手法を学びましょう。

「難しそう」と思うかもしれませんが、一つずつ覚えていけば大丈夫です。

React.memoで不要な再レンダリングを防ごう

// 最適化前
function UserCard({ user, onEdit }) {
  console.log('UserCard rendered'); // 毎回実行される
  
  return (
    <div className="user-card">
      <img src={user.avatar} alt={user.name} />
      <h3>{user.name}</h3>
      <p>{user.email}</p>
      <button onClick={() => onEdit(user.id)}>編集</button>
    </div>
  );
}

// 最適化後
const UserCard = React.memo(function UserCard({ user, onEdit }) {
  console.log('UserCard rendered'); // propsが変わった時のみ実行
  
  return (
    <div className="user-card">
      <img src={user.avatar} alt={user.name} />
      <h3>{user.name}</h3>
      <p>{user.email}</p>
      <button onClick={() => onEdit(user.id)}>編集</button>
    </div>
  );
});

// 使用例
function UserList() {
  const [users, setUsers] = useState([]);
  const [searchTerm, setSearchTerm] = useState('');
  
  const handleEdit = useCallback((userId) => {
    console.log('Editing user:', userId);
  }, []);
  
  return (
    <div>
      <input
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="ユーザー検索"
      />
      
      {users.map(user => (
        <UserCard
          key={user.id}
          user={user}
          onEdit={handleEdit} // useCallbackで最適化
        />
      ))}
    </div>
  );
}

React.memoを使うことで、propsが変わった時のみコンポーネントが再レンダリングされます。 「無駄な処理を減らす」ことができるんです。

useMemoで重い計算をキャッシュしよう

function Dashboard({ transactions, startDate, endDate }) {
  // 最適化前:毎回計算が実行される
  // const filteredTransactions = transactions.filter(t => 
  //   t.date >= startDate && t.date <= endDate
  // );
  // const totalAmount = filteredTransactions.reduce((sum, t) => sum + t.amount, 0);
  
  // 最適化後:依存配列が変わった時のみ計算
  const filteredTransactions = useMemo(() => {
    console.log('Filtering transactions...');
    return transactions.filter(transaction => 
      transaction.date >= startDate && transaction.date <= endDate
    );
  }, [transactions, startDate, endDate]);
  
  const statistics = useMemo(() => {
    console.log('Calculating statistics...');
    const total = filteredTransactions.reduce((sum, t) => sum + t.amount, 0);
    const average = total / filteredTransactions.length || 0;
    const categories = groupByCategory(filteredTransactions);
    
    return { total, average, categories };
  }, [filteredTransactions]);
  
  return (
    <div className="dashboard">
      <h1>取引ダッシュボード</h1>
      <div className="stats">
        <div>合計: {statistics.total.toLocaleString()}円</div>
        <div>平均: {statistics.average.toLocaleString()}円</div>
        <div>件数: {filteredTransactions.length}件</div>
      </div>
      
      <TransactionChart data={statistics.categories} />
      <TransactionList transactions={filteredTransactions} />
    </div>
  );
}

useMemoを使うことで、「同じ入力に対して同じ計算を繰り返さない」ようになります。 計算結果をキャッシュして、効率的に処理できるんです。

useCallbackで関数をキャッシュしよう

function TodoApp() {
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState('all');
  
  // 最適化前:毎回新しい関数が作成される
  // const addTodo = (text) => {
  //   setTodos(prev => [...prev, { id: Date.now(), text, completed: false }]);
  // };
  
  // 最適化後:関数をキャッシュ
  const addTodo = useCallback((text) => {
    setTodos(prev => [...prev, { 
      id: Date.now(), 
      text, 
      completed: false 
    }]);
  }, []);
  
  const toggleTodo = useCallback((id) => {
    setTodos(prev => prev.map(todo => 
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  }, []);
  
  const deleteTodo = useCallback((id) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  }, []);
  
  const filteredTodos = useMemo(() => {
    switch (filter) {
      case 'completed':
        return todos.filter(todo => todo.completed);
      case 'active':
        return todos.filter(todo => !todo.completed);
      default:
        return todos;
    }
  }, [todos, filter]);
  
  return (
    <div className="todo-app">
      <TodoForm onAdd={addTodo} />
      <TodoFilter current={filter} onChange={setFilter} />
      <TodoList
        todos={filteredTodos}
        onToggle={toggleTodo}
        onDelete={deleteTodo}
      />
    </div>
  );
}

// 子コンポーネントもメモ化
const TodoItem = React.memo(function TodoItem({ todo, onToggle, onDelete }) {
  return (
    <li className={`todo-item ${todo.completed ? 'completed' : ''}`}>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
      />
      <span>{todo.text}</span>
      <button onClick={() => onDelete(todo.id)}>削除</button>
    </li>
  );
});

useCallbackを使うことで、「関数が毎回再作成されることを防ぐ」ことができます。 子コンポーネントに関数を渡す時に特に効果的です。

適切なkey属性を使おう

// 問題のあるコード
function BadTodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo, index) => (
        <li key={index}> {/* インデックスは避ける */}
          {todo.text}
        </li>
      ))}
    </ul>
  );
}

// 最適化されたコード
function GoodTodoList({ todos }) {
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}> {/* 一意なIDを使用 */}
          {todo.text}
        </li>
      ))}
    </ul>
  );
}

// より複雑な例
function AdvancedTodoList({ todos, onToggle, onEdit, onDelete }) {
  return (
    <ul className="todo-list">
      {todos.map(todo => (
        <TodoItem
          key={todo.id} // 安定した一意なkey
          todo={todo}
          onToggle={onToggle}
          onEdit={onEdit}
          onDelete={onDelete}
        />
      ))}
    </ul>
  );
}

適切なkey属性を使うことで、「Reactが効率的にDOMを更新できる」ようになります。 インデックスではなく、一意なIDを使うのがポイントです。

条件付きレンダリングを最適化しよう

function UserProfile({ user, showDetails, showActivities }) {
  return (
    <div className="user-profile">
      <div className="user-basic">
        <img src={user.avatar} alt={user.name} />
        <h1>{user.name}</h1>
        <p>{user.bio}</p>
      </div>
      
      {/* 条件が満たされた時のみコンポーネントを作成 */}
      {showDetails && (
        <UserDetails user={user} />
      )}
      
      {showActivities && (
        <UserActivities userId={user.id} />
      )}
    </div>
  );
}

// さらに最適化:遅延読み込み
const UserDetails = React.lazy(() => import('./UserDetails'));
const UserActivities = React.lazy(() => import('./UserActivities'));

function OptimizedUserProfile({ user, showDetails, showActivities }) {
  return (
    <div className="user-profile">
      <div className="user-basic">
        <img src={user.avatar} alt={user.name} />
        <h1>{user.name}</h1>
        <p>{user.bio}</p>
      </div>
      
      <Suspense fallback={<div>読み込み中...</div>}>
        {showDetails && (
          <UserDetails user={user} />
        )}
        
        {showActivities && (
          <UserActivities userId={user.id} />
        )}
      </Suspense>
    </div>
  );
}

「必要な時のみコンポーネントを作成・読み込み」することで、初期表示が高速化されます。

状態を適切に分割しよう

// 最適化前:一つの大きな状態
function App() {
  const [appState, setAppState] = useState({
    user: null,
    todos: [],
    settings: {},
    notifications: []
  });
  
  // 通知を追加するだけで全てが再レンダリング
  const addNotification = (notification) => {
    setAppState(prev => ({
      ...prev,
      notifications: [...prev.notifications, notification]
    }));
  };
  
  return (
    <div>
      <UserSection user={appState.user} />
      <TodoSection todos={appState.todos} />
      <SettingsSection settings={appState.settings} />
      <NotificationSection notifications={appState.notifications} />
    </div>
  );
}

// 最適化後:状態を分割
function OptimizedApp() {
  const [user, setUser] = useState(null);
  const [todos, setTodos] = useState([]);
  const [settings, setSettings] = useState({});
  const [notifications, setNotifications] = useState([]);
  
  // 通知追加時は NotificationSection のみ再レンダリング
  const addNotification = useCallback((notification) => {
    setNotifications(prev => [...prev, notification]);
  }, []);
  
  return (
    <div>
      <UserSection user={user} />
      <TodoSection todos={todos} />
      <SettingsSection settings={settings} />
      <NotificationSection 
        notifications={notifications}
        onAdd={addNotification}
      />
    </div>
  );
}

状態を適切に分割することで、「変更の影響範囲を最小限に抑える」ことができます。

これらの基本的な最適化手法を適用するだけで、多くのパフォーマンス問題を解決できます。 「まずは基本から」というアプローチが重要ですね。

仮想化とコード分割で大幅改善しよう

大量のデータや大きなアプリケーションのパフォーマンスを改善する高度な手法を学びましょう。

「さらに高速化したい」という時に役立つテクニックです。

仮想スクロールで大量データに対応しよう

import { FixedSizeList as List } from 'react-window';

// 最適化前:10,000件すべてをレンダリング
function LargeList({ items }) {
  return (
    <div className="large-list">
      {items.map(item => (
        <div key={item.id} className="list-item">
          <img src={item.avatar} alt={item.name} />
          <div>
            <h3>{item.name}</h3>
            <p>{item.description}</p>
          </div>
        </div>
      ))}
    </div>
  );
}

// 最適化後:表示領域の項目のみレンダリング
function VirtualizedList({ items }) {
  const Row = ({ index, style }) => (
    <div style={style} className="list-item">
      <img src={items[index].avatar} alt={items[index].name} />
      <div>
        <h3>{items[index].name}</h3>
        <p>{items[index].description}</p>
      </div>
    </div>
  );
  
  return (
    <List
      height={600} // リストの高さ
      itemCount={items.length}
      itemSize={100} // 各項目の高さ
      width="100%"
    >
      {Row}
    </List>
  );
}

// 可変サイズの項目に対応
import { VariableSizeList as VariableList } from 'react-window';

function VariableSizeVirtualList({ items }) {
  // 各項目の高さを計算
  const getItemSize = (index) => {
    const item = items[index];
    // 内容に応じて高さを動的に計算
    return item.description.length > 100 ? 150 : 100;
  };
  
  const Row = ({ index, style }) => (
    <div style={style} className="variable-list-item">
      <h3>{items[index].name}</h3>
      <p>{items[index].description}</p>
    </div>
  );
  
  return (
    <VariableList
      height={600}
      itemCount={items.length}
      itemSize={getItemSize}
      width="100%"
    >
      {Row}
    </VariableList>
  );
}

仮想スクロールを使うことで、「1万件のデータでも表示領域の分だけレンダリング」するため、高速に動作します。 「全部作らずに、見える分だけ作る」のがポイントです。

無限スクロールでデータを分割読み込みしよう

function InfiniteScrollList() {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const [page, setPage] = useState(1);
  
  const loadMoreItems = useCallback(async () => {
    if (loading || !hasMore) return;
    
    setLoading(true);
    try {
      const response = await fetch(`/api/items?page=${page}&limit=20`);
      const newItems = await response.json();
      
      if (newItems.length === 0) {
        setHasMore(false);
      } else {
        setItems(prev => [...prev, ...newItems]);
        setPage(prev => prev + 1);
      }
    } catch (error) {
      console.error('Failed to load items:', error);
    } finally {
      setLoading(false);
    }
  }, [page, loading, hasMore]);
  
  // Intersection Observer を使用した自動読み込み
  const observerRef = useRef();
  const lastItemRef = useCallback((node) => {
    if (loading) return;
    if (observerRef.current) observerRef.current.disconnect();
    
    observerRef.current = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting && hasMore) {
        loadMoreItems();
      }
    });
    
    if (node) observerRef.current.observe(node);
  }, [loading, hasMore, loadMoreItems]);
  
  useEffect(() => {
    loadMoreItems(); // 初回読み込み
  }, []);
  
  return (
    <div className="infinite-scroll-list">
      {items.map((item, index) => (
        <div
          key={item.id}
          ref={index === items.length - 1 ? lastItemRef : null}
          className="list-item"
        >
          <h3>{item.title}</h3>
          <p>{item.description}</p>
        </div>
      ))}
      
      {loading && (
        <div className="loading-indicator">
          読み込み中...
        </div>
      )}
      
      {!hasMore && (
        <div className="end-message">
          すべての項目を読み込みました
        </div>
      )}
    </div>
  );
}

無限スクロールにより、「必要な分だけデータを取得」することで、初期表示が高速化されます。 「最初に全部取得しない」のがコツです。

コード分割で必要な時だけ読み込もう

// ルートレベルでの分割
import { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// 遅延読み込みコンポーネント
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <BrowserRouter>
      <div className="app">
        <nav>
          <Link to="/">ホーム</Link>
          <Link to="/about">概要</Link>
          <Link to="/dashboard">ダッシュボード</Link>
          <Link to="/settings">設定</Link>
        </nav>
        
        <main>
          <Suspense fallback={<div className="loading">読み込み中...</div>}>
            <Routes>
              <Route path="/" element={<Home />} />
              <Route path="/about" element={<About />} />
              <Route path="/dashboard" element={<Dashboard />} />
              <Route path="/settings" element={<Settings />} />
            </Routes>
          </Suspense>
        </main>
      </div>
    </BrowserRouter>
  );
}

// 機能レベルでの分割
function UserProfile({ userId }) {
  const [showAdvanced, setShowAdvanced] = useState(false);
  
  // 高度な機能は必要な時のみ読み込み
  const AdvancedSettings = lazy(() => import('./AdvancedSettings'));
  const UserAnalytics = lazy(() => import('./UserAnalytics'));
  
  return (
    <div className="user-profile">
      <BasicUserInfo userId={userId} />
      
      <button onClick={() => setShowAdvanced(!showAdvanced)}>
        {showAdvanced ? '簡易表示' : '詳細表示'}
      </button>
      
      {showAdvanced && (
        <Suspense fallback={<div>詳細情報を読み込み中...</div>}>
          <AdvancedSettings userId={userId} />
          <UserAnalytics userId={userId} />
        </Suspense>
      )}
    </div>
  );
}

コード分割により、「使わない機能のコードは読み込まない」ことで、初期表示が高速化されます。

動的インポートで賢く読み込もう

function ChartComponent({ data, chartType }) {
  const [ChartLib, setChartLib] = useState(null);
  const [loading, setLoading] = useState(false);
  
  useEffect(() => {
    let mounted = true;
    
    const loadChart = async () => {
      setLoading(true);
      
      try {
        let chartModule;
        
        // チャートタイプに応じて動的にライブラリを読み込み
        switch (chartType) {
          case 'line':
            chartModule = await import('./charts/LineChart');
            break;
          case 'bar':
            chartModule = await import('./charts/BarChart');
            break;
          case 'pie':
            chartModule = await import('./charts/PieChart');
            break;
          default:
            chartModule = await import('./charts/DefaultChart');
        }
        
        if (mounted) {
          setChartLib(chartModule.default);
        }
      } catch (error) {
        console.error('Failed to load chart:', error);
      } finally {
        if (mounted) {
          setLoading(false);
        }
      }
    };
    
    loadChart();
    
    return () => {
      mounted = false;
    };
  }, [chartType]);
  
  if (loading) {
    return <div>チャートを読み込み中...</div>;
  }
  
  if (!ChartLib) {
    return <div>チャートを読み込めませんでした</div>;
  }
  
  return <ChartLib data={data} />;
}

// ユーティリティ関数による動的インポート
async function loadComponent(componentName) {
  try {
    const module = await import(`./components/${componentName}`);
    return module.default;
  } catch (error) {
    console.error(`Failed to load component: ${componentName}`, error);
    return null;
  }
}

function DynamicComponentLoader({ componentName, ...props }) {
  const [Component, setComponent] = useState(null);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    loadComponent(componentName)
      .then(setComponent)
      .catch(setError);
  }, [componentName]);
  
  if (error) {
    return <div>コンポーネントの読み込みに失敗しました</div>;
  }
  
  if (!Component) {
    return <div>読み込み中...</div>;
  }
  
  return <Component {...props} />;
}

動的インポートにより、「必要なタイミングで必要なコードのみ読み込む」ことができます。 「全部準備しておく」のではなく、「必要になったら取りに行く」アプローチです。

プリロード戦略で体感速度を向上させよう

// ページ遷移前のプリロード
function NavigationWithPreload() {
  const preloadComponent = useCallback((path) => {
    // マウスオーバー時にコンポーネントをプリロード
    switch (path) {
      case '/dashboard':
        import('./pages/Dashboard');
        break;
      case '/settings':
        import('./pages/Settings');
        break;
      case '/analytics':
        import('./pages/Analytics');
        break;
    }
  }, []);
  
  return (
    <nav className="navigation">
      <Link 
        to="/dashboard"
        onMouseEnter={() => preloadComponent('/dashboard')}
      >
        ダッシュボード
      </Link>
      
      <Link 
        to="/settings"
        onMouseEnter={() => preloadComponent('/settings')}
      >
        設定
      </Link>
      
      <Link 
        to="/analytics"
        onMouseEnter={() => preloadComponent('/analytics')}
      >
        分析
      </Link>
    </nav>
  );
}

// Intersection Observer によるプリロード
function LazyComponentWithPreload({ children, componentPath }) {
  const [isVisible, setIsVisible] = useState(false);
  const [Component, setComponent] = useState(null);
  const ref = useRef();
  
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          // 要素が見える前にコンポーネントを読み込み開始
          import(componentPath).then(module => {
            setComponent(() => module.default);
          });
        }
      },
      { rootMargin: '100px' } // 100px手前でトリガー
    );
    
    if (ref.current) {
      observer.observe(ref.current);
    }
    
    return () => observer.disconnect();
  }, [componentPath]);
  
  return (
    <div ref={ref}>
      {isVisible && Component ? (
        <Component>{children}</Component>
      ) : (
        <div className="placeholder">コンテンツを準備中...</div>
      )}
    </div>
  );
}

プリロード戦略により、「ユーザーが使いそうなものを先回りして準備」することで、体感速度が向上します。

これらの高度な最適化手法により、大規模なアプリケーションでも快適なユーザー体験を提供できます。 「基本ができたら次のステップ」として活用してくださいね。

画像とアセットを軽量化しよう

Webアプリケーションにおいて、画像やその他のアセットは大きなパフォーマンス影響を与えます。

「画像が重くてページが遅い」という問題を解決していきましょう。

画像を効率的に読み込もう

// 最適化前:問題のある画像読み込み
function BadImageGallery({ images }) {
  return (
    <div className="gallery">
      {images.map(image => (
        <img
          key={image.id}
          src={image.originalUrl} // 高解像度画像を直接読み込み
          alt={image.title}
          style={{ width: '300px', height: '200px' }} // CSSでリサイズ
        />
      ))}
    </div>
  );
}

// 最適化後:レスポンシブ画像とWebP対応
function OptimizedImageGallery({ images }) {
  return (
    <div className="gallery">
      {images.map(image => (
        <picture key={image.id}>
          {/* WebP対応ブラウザ用 */}
          <source
            srcSet={`
              ${image.webp.small} 300w,
              ${image.webp.medium} 600w,
              ${image.webp.large} 1200w
            `}
            sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
            type="image/webp"
          />
          
          {/* フォールバック用 */}
          <img
            src={image.medium}
            srcSet={`
              ${image.small} 300w,
              ${image.medium} 600w,
              ${image.large} 1200w
            `}
            sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
            alt={image.title}
            loading="lazy" // 遅延読み込み
            className="gallery-image"
          />
        </picture>
      ))}
    </div>
  );
}

この最適化により、「デバイスに適したサイズの画像を自動選択」して、読み込み時間を大幅に短縮できます。

遅延読み込みで初期表示を高速化しよう

// シンプルな遅延読み込み
function LazyImage({ src, alt, placeholder = '/placeholder.jpg' }) {
  const [imageSrc, setImageSrc] = useState(placeholder);
  const [isLoaded, setIsLoaded] = useState(false);
  const [isInView, setIsInView] = useState(false);
  const imgRef = useRef();
  
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsInView(true);
          observer.disconnect();
        }
      },
      { threshold: 0.1 }
    );
    
    if (imgRef.current) {
      observer.observe(imgRef.current);
    }
    
    return () => observer.disconnect();
  }, []);
  
  useEffect(() => {
    if (isInView && src) {
      const imageLoader = new Image();
      imageLoader.src = src;
      imageLoader.onload = () => {
        setImageSrc(src);
        setIsLoaded(true);
      };
    }
  }, [isInView, src]);
  
  return (
    <div className="lazy-image-container" ref={imgRef}>
      <img
        src={imageSrc}
        alt={alt}
        className={`lazy-image ${isLoaded ? 'loaded' : 'loading'}`}
        style={{
          filter: isLoaded ? 'none' : 'blur(5px)',
          transition: 'filter 0.3s ease'
        }}
      />
      {!isLoaded && (
        <div className="loading-overlay">
          <div className="spinner"></div>
        </div>
      )}
    </div>
  );
}

// 高度な画像コンポーネント
function AdvancedImage({ 
  src, 
  alt, 
  width, 
  height, 
  placeholder, 
  onLoad, 
  onError 
}) {
  const [loadState, setLoadState] = useState('loading');
  const [isInView, setIsInView] = useState(false);
  const imgRef = useRef();
  
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsInView(true);
          observer.disconnect();
        }
      },
      { 
        threshold: 0.1,
        rootMargin: '50px' // 50px手前で読み込み開始
      }
    );
    
    if (imgRef.current) {
      observer.observe(imgRef.current);
    }
    
    return () => observer.disconnect();
  }, []);
  
  const handleLoad = useCallback(() => {
    setLoadState('loaded');
    onLoad && onLoad();
  }, [onLoad]);
  
  const handleError = useCallback(() => {
    setLoadState('error');
    onError && onError();
  }, [onError]);
  
  return (
    <div 
      className="advanced-image-container"
      style={{ width, height }}
      ref={imgRef}
    >
      {isInView && (
        <img
          src={src}
          alt={alt}
          onLoad={handleLoad}
          onError={handleError}
          className={`advanced-image state-${loadState}`}
        />
      )}
      
      {loadState === 'loading' && placeholder && (
        <div className="image-placeholder">
          <img src={placeholder} alt="" />
        </div>
      )}
      
      {loadState === 'error' && (
        <div className="image-error">
          <span>画像を読み込めませんでした</span>
        </div>
      )}
    </div>
  );
}

遅延読み込みにより、「見えるタイミングで画像を読み込む」ことで、初期表示が高速化されます。

プログレッシブ画像で体感速度を向上させよう

function ProgressiveImage({ src, placeholder, alt }) {
  const [imageSrc, setImageSrc] = useState(placeholder);
  const [isLoaded, setIsLoaded] = useState(false);
  const [loadingProgress, setLoadingProgress] = useState(0);
  
  useEffect(() => {
    const img = new Image();
    
    // 読み込み進捗の監視
    let loaded = 0;
    const total = 100; // 仮の値
    
    const progressInterval = setInterval(() => {
      loaded += Math.random() * 10;
      if (loaded >= total) {
        loaded = total;
        clearInterval(progressInterval);
      }
      setLoadingProgress(Math.round(loaded));
    }, 100);
    
    img.onload = () => {
      clearInterval(progressInterval);
      setLoadingProgress(100);
      setImageSrc(src);
      setIsLoaded(true);
    };
    
    img.onerror = () => {
      clearInterval(progressInterval);
      setLoadingProgress(0);
    };
    
    img.src = src;
    
    return () => {
      clearInterval(progressInterval);
    };
  }, [src]);
  
  return (
    <div className="progressive-image">
      <img
        src={imageSrc}
        alt={alt}
        className={`progressive-img ${isLoaded ? 'loaded' : ''}`}
      />
      
      {!isLoaded && (
        <div className="loading-progress">
          <div 
            className="progress-bar"
            style={{ width: `${loadingProgress}%` }}
          />
          <span className="progress-text">{loadingProgress}%</span>
        </div>
      )}
    </div>
  );
}

プログレッシブ画像により、「読み込み状況が分かる」ため、ユーザーの待ち時間のストレスが軽減されます。

その他のアセットも最適化しよう

// フォントの最適化
function FontOptimization() {
  // フォントのプリロード
  useEffect(() => {
    const link = document.createElement('link');
    link.rel = 'preload';
    link.href = '/fonts/custom-font.woff2';
    link.as = 'font';
    link.type = 'font/woff2';
    link.crossOrigin = 'anonymous';
    document.head.appendChild(link);
    
    return () => {
      document.head.removeChild(link);
    };
  }, []);
  
  return (
    <div style={{ fontFamily: 'CustomFont, sans-serif' }}>
      フォント最適化されたテキスト
    </div>
  );
}

// CSSの動的読み込み
function DynamicStyleLoader({ theme }) {
  useEffect(() => {
    const loadThemeCSS = async () => {
      try {
        // 必要な時のみCSSを読み込み
        await import(`./themes/${theme}.css`);
      } catch (error) {
        console.error(`Failed to load theme: ${theme}`, error);
      }
    };
    
    loadThemeCSS();
  }, [theme]);
  
  return <div className={`theme-${theme}`}>テーマ適用済みコンテンツ</div>;
}

// Service Worker によるキャッシュ戦略
function ServiceWorkerCache() {
  useEffect(() => {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('/sw.js')
        .then(registration => {
          console.log('SW registered:', registration);
        })
        .catch(error => {
          console.log('SW registration failed:', error);
        });
    }
  }, []);
  
  return null;
}

フォント、CSS、Service Workerの最適化により、「全体的な読み込み速度」が向上します。

バンドルサイズを削減しよう

// Tree Shakingの活用
// 悪い例:ライブラリ全体をインポート
// import _ from 'lodash';

// 良い例:必要な関数のみインポート
import { debounce, throttle } from 'lodash';

// さらに良い例:個別インポート
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';

// 動的インポートでバンドルサイズを削減
function AnalyticsComponent() {
  const [chartData, setChartData] = useState(null);
  
  const loadChart = useCallback(async () => {
    // 必要な時のみ大きなライブラリを読み込み
    const { Chart } = await import('chart.js');
    // チャートの初期化...
  }, []);
  
  return (
    <div>
      <button onClick={loadChart}>
        チャートを表示
      </button>
      {chartData && <div id="chart-container"></div>}
    </div>
  );
}

// ポリフィルの条件付き読み込み
function ConditionalPolyfill() {
  useEffect(() => {
    // 必要な機能がサポートされていない場合のみポリフィルを読み込み
    if (!window.IntersectionObserver) {
      import('intersection-observer');
    }
    
    if (!Element.prototype.closest) {
      import('element-closest');
    }
  }, []);
  
  return null;
}

バンドルサイズの削減により、「ダウンロード時間の短縮」と「実行速度の向上」が実現できます。

これらの最適化により、アプリケーションの読み込み時間と実行時パフォーマンスを大幅に改善できます。 「小さな改善の積み重ね」が大きな効果を生むんです。

パフォーマンスを測定・分析しよう

パフォーマンス最適化を効果的に行うには、まず現状を正確に測定することが重要です。

「何が遅いのか分からない」状態から脱却しましょう。

React DevToolsで問題を見つけよう

// プロファイラーAPIを使った測定
function ProfiledApp() {
  const onRenderCallback = useCallback((id, phase, actualDuration) => {
    console.log('Profiler data:', {
      id,           // プロファイルされたコンポーネントの識別子
      phase,        // "mount" (初回) または "update" (更新)
      actualDuration // 実際のレンダリング時間
    });
  }, []);
  
  return (
    <Profiler id="App" onRender={onRenderCallback}>
      <div className="app">
        <Header />
        <MainContent />
        <Footer />
      </div>
    </Profiler>
  );
}

// 開発モードでのパフォーマンス監視
function PerformanceMonitor({ children, componentName }) {
  useEffect(() => {
    if (process.env.NODE_ENV === 'development') {
      const startTime = performance.now();
      
      return () => {
        const endTime = performance.now();
        console.log(`${componentName} render time: ${endTime - startTime}ms`);
      };
    }
  });
  
  return children;
}

// 使用例
function MonitoredComponent() {
  return (
    <PerformanceMonitor componentName="MonitoredComponent">
      <div>重い処理を含むコンポーネント</div>
    </PerformanceMonitor>
  );
}

React DevToolsのProfiler機能により、「どのコンポーネントが重いか」を具体的に特定できます。

カスタムフックで継続的に監視しよう

// レンダリング時間測定フック
function useRenderTime(componentName) {
  const renderStartTime = useRef();
  const [renderTimes, setRenderTimes] = useState([]);
  
  // レンダリング開始時
  renderStartTime.current = performance.now();
  
  useEffect(() => {
    // レンダリング完了時
    const endTime = performance.now();
    const duration = endTime - renderStartTime.current;
    
    setRenderTimes(prev => [...prev.slice(-9), duration]); // 最新10回を保持
    
    if (process.env.NODE_ENV === 'development') {
      console.log(`${componentName} render time: ${duration.toFixed(2)}ms`);
    }
  });
  
  return {
    averageRenderTime: renderTimes.reduce((a, b) => a + b, 0) / renderTimes.length,
    renderTimes
  };
}

// メモリ使用量監視フック
function useMemoryMonitor() {
  const [memoryInfo, setMemoryInfo] = useState(null);
  
  useEffect(() => {
    const updateMemoryInfo = () => {
      if (performance.memory) {
        setMemoryInfo({
          used: Math.round(performance.memory.usedJSHeapSize / 1048576), // MB
          total: Math.round(performance.memory.totalJSHeapSize / 1048576), // MB
          limit: Math.round(performance.memory.jsHeapSizeLimit / 1048576) // MB
        });
      }
    };
    
    updateMemoryInfo();
    const interval = setInterval(updateMemoryInfo, 5000); // 5秒ごと
    
    return () => clearInterval(interval);
  }, []);
  
  return memoryInfo;
}

// 使用例
function PerformanceAwareComponent() {
  const { averageRenderTime } = useRenderTime('PerformanceAwareComponent');
  const memoryInfo = useMemoryMonitor();
  
  return (
    <div>
      <h2>パフォーマンス情報</h2>
      {process.env.NODE_ENV === 'development' && (
        <div className="performance-info">
          <p>平均レンダリング時間: {averageRenderTime?.toFixed(2)}ms</p>
          {memoryInfo && (
            <p>
              メモリ使用量: {memoryInfo.used}MB / {memoryInfo.total}MB
            </p>
          )}
        </div>
      )}
    </div>
  );
}

カスタムフックにより、「開発中に常にパフォーマンスを監視」することができます。

Web Vitalsでユーザー体験を測定しよう

// Core Web Vitals の測定
function WebVitalsReporter() {
  useEffect(() => {
    // Largest Contentful Paint (LCP)
    const observer = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const lastEntry = entries[entries.length - 1];
      console.log('LCP:', lastEntry.startTime);
    });
    observer.observe({ entryTypes: ['largest-contentful-paint'] });
    
    // First Input Delay (FID) の測定
    const fidObserver = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      entries.forEach((entry) => {
        console.log('FID:', entry.processingStart - entry.startTime);
      });
    });
    fidObserver.observe({ entryTypes: ['first-input'] });
    
    // Cumulative Layout Shift (CLS) の測定
    let clsValue = 0;
    const clsObserver = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      entries.forEach((entry) => {
        if (!entry.hadRecentInput) {
          clsValue += entry.value;
          console.log('CLS:', clsValue);
        }
      });
    });
    clsObserver.observe({ entryTypes: ['layout-shift'] });
    
    return () => {
      observer.disconnect();
      fidObserver.disconnect();
      clsObserver.disconnect();
    };
  }, []);
  
  return null;
}

// パフォーマンス分析ダッシュボード
function PerformanceDashboard() {
  const [metrics, setMetrics] = useState({
    loadTime: 0,
    renderTime: 0,
    memoryUsage: 0,
    bundleSize: 0
  });
  
  useEffect(() => {
    // ページ読み込み時間
    const loadTime = performance.timing.loadEventEnd - performance.timing.navigationStart;
    
    // バンドルサイズの取得
    const resources = performance.getEntriesByType('resource');
    const jsResources = resources.filter(resource => 
      resource.name.includes('.js') && resource.transferSize
    );
    const totalBundleSize = jsResources.reduce((total, resource) => 
      total + resource.transferSize, 0
    );
    
    setMetrics(prev => ({
      ...prev,
      loadTime: loadTime,
      bundleSize: Math.round(totalBundleSize / 1024) // KB
    }));
  }, []);
  
  return (
    <div className="performance-dashboard">
      <h2>パフォーマンス メトリクス</h2>
      <div className="metrics-grid">
        <div className="metric">
          <h3>ページ読み込み時間</h3>
          <span className={metrics.loadTime > 3000 ? 'warning' : 'good'}>
            {metrics.loadTime}ms
          </span>
        </div>
        
        <div className="metric">
          <h3>バンドルサイズ</h3>
          <span className={metrics.bundleSize > 500 ? 'warning' : 'good'}>
            {metrics.bundleSize}KB
          </span>
        </div>
        
        <div className="metric">
          <h3>レンダリング時間</h3>
          <span>{metrics.renderTime}ms</span>
        </div>
        
        <div className="metric">
          <h3>メモリ使用量</h3>
          <span>{metrics.memoryUsage}MB</span>
        </div>
      </div>
    </div>
  );
}

Web Vitalsの測定により、「実際のユーザー体験」を数値で把握できます。

リアルタイム監視で問題を早期発見しよう

// リアルタイムパフォーマンス監視
function usePerformanceMonitoring() {
  const [performanceData, setPerformanceData] = useState({
    fps: 0,
    renderTime: 0,
    memoryUsage: 0
  });
  
  useEffect(() => {
    let frameCount = 0;
    let lastTime = performance.now();
    let animationId;
    
    const measureFPS = () => {
      frameCount++;
      const currentTime = performance.now();
      
      if (currentTime >= lastTime + 1000) {
        setPerformanceData(prev => ({
          ...prev,
          fps: Math.round(frameCount * 1000 / (currentTime - lastTime))
        }));
        
        frameCount = 0;
        lastTime = currentTime;
      }
      
      animationId = requestAnimationFrame(measureFPS);
    };
    
    measureFPS();
    
    // メモリ使用量の定期監視
    const memoryInterval = setInterval(() => {
      if (performance.memory) {
        setPerformanceData(prev => ({
          ...prev,
          memoryUsage: Math.round(performance.memory.usedJSHeapSize / 1048576)
        }));
      }
    }, 2000);
    
    return () => {
      cancelAnimationFrame(animationId);
      clearInterval(memoryInterval);
    };
  }, []);
  
  return performanceData;
}

// パフォーマンス警告システム
function PerformanceWarningSystem() {
  const performanceData = usePerformanceMonitoring();
  const [warnings, setWarnings] = useState([]);
  
  useEffect(() => {
    const newWarnings = [];
    
    if (performanceData.fps < 30) {
      newWarnings.push('FPSが低下しています (FPS: ' + performanceData.fps + ')');
    }
    
    if (performanceData.memoryUsage > 100) {
      newWarnings.push('メモリ使用量が多いです (' + performanceData.memoryUsage + 'MB)');
    }
    
    setWarnings(newWarnings);
  }, [performanceData]);
  
  if (process.env.NODE_ENV !== 'development') {
    return null;
  }
  
  return (
    <div className="performance-warnings">
      {warnings.map((warning, index) => (
        <div key={index} className="warning">
          ⚠️ {warning}
        </div>
      ))}
    </div>
  );
}

リアルタイム監視により、「問題が発生した瞬間に気づく」ことができます。

これらの測定と分析ツールを活用することで、パフォーマンス問題を早期に発見し、効果的な最適化を行うことができます。 「測定なしに改善なし」ということを忘れずに、継続的に監視していきましょう。

まとめ:快適なReactアプリを作ろう!

お疲れ様でした! Reactアプリケーションが重くなる原因と、その対策について詳しく解説してきました。

主な原因を理解しよう

覚えておきたい重要な原因です。

  • 不要な再レンダリングが最も多い問題
  • 大量データの一括描画で初期表示が遅い
  • 重い計算処理を毎回繰り返している
  • メモリリークでリソースが残り続ける
  • 非効率な状態管理で影響範囲が大きい
  • 最適化されていない画像・アセットが重い

基本的な最適化手法

まず実践すべき対策です。

  • React.memo: コンポーネントの不要な再レンダリング防止
  • useMemo: 重い計算結果のキャッシュ
  • useCallback: 関数参照の最適化
  • 適切なkey属性: リストの効率的な更新
  • 状態の分割: 変更の影響範囲を最小限に

高度な最適化技術

さらなる改善のためのテクニックです。

  • 仮想スクロール: 大量データの効率的な表示
  • 無限スクロール: データの段階的読み込み
  • コード分割: 必要な時のみコンポーネント読み込み
  • 動的インポート: ライブラリの遅延読み込み
  • プリロード戦略: 先回りして必要なリソースを準備

アセット最適化

読み込み速度向上のポイントです。

  • 画像の最適化: WebP対応、レスポンシブ画像
  • 遅延読み込み: 必要な時のみリソース読み込み
  • バンドルサイズ削減: Tree Shaking、動的インポート
  • キャッシュ戦略: Service Workerの活用

パフォーマンス測定

改善効果を確認する方法です。

  • React DevTools: プロファイリングとデバッグ
  • Web Vitals: ユーザー体験の指標測定
  • カスタム監視: 独自のパフォーマンス追跡
  • リアルタイム監視: 問題の早期発見

最適化の優先順位

効果的に取り組む順番です。

  1. まず基本から: React.memo、useMemo、useCallbackの適用
  2. 次に画像最適化: 遅延読み込みとレスポンシブ画像
  3. コード分割: 大きなコンポーネントの分割
  4. 高度な手法: 仮想スクロールや動的インポート

実践のコツ

成功するためのポイントです。

  • 測定から始めて、推測ではなくデータに基づいて最適化する
  • 段階的に改善して、効果を確認しながら進める
  • ユーザー体験を重視して、技術的な数値だけでなく使用感を考慮する
  • 継続的な監視で、最適化を一度きりで終わらせない

成果の例

適切な最適化による改善効果です。

  • 読み込み時間: 5.2秒 → 2.1秒(60%改善)
  • レンダリング時間: 850ms → 180ms(79%改善)
  • バンドルサイズ: 2.3MB → 1.1MB(52%改善)
  • メモリ使用量: 156MB → 89MB(43%改善)

最終的なアドバイス

長期的な成功のために覚えておいてください。

パフォーマンス最適化は、ユーザー体験の大幅な改善につながります。 「小さな改善の積み重ね」が、大きな効果を生み出すんです。

この知識を活用して、快適で高速なアプリケーションを作ってください。 「重いアプリ」から「サクサク動くアプリ」への変化を、ぜひ体験してみてくださいね!

継続的な改善により、ユーザーに愛される高品質なReactアプリケーションを維持できますよ。

関連記事