はじめに

前回の記事では、VitestとReact Testing Libraryを使ったコンポーネントのテスト方法を解説しました。本記事では、Reactアプリケーションのパフォーマンス最適化について、特に「無駄な再レンダリングを防ぐ」という観点から詳しく解説します。

Reactは仮想DOMを使った効率的な差分更新を行いますが、コンポーネントの設計次第では不要な再レンダリングが発生し、アプリケーションのパフォーマンスが低下することがあります。2025年現在、React 19ではReact Compilerによる自動最適化も導入されていますが、手動での最適化テクニックを理解しておくことは依然として重要です。

本記事を読むことで、以下のことができるようになります。

  • Reactの再レンダリングの仕組みを理解する
  • React DevToolsでパフォーマンスを分析する
  • memouseMemouseCallbackを適切に使い分ける
  • コード分割とLazy Loadingで初期読み込みを高速化する
  • パフォーマンス最適化のベストプラクティスを実践する

実行環境・前提条件

必要な環境

  • Node.js 20.x以上
  • Viteで作成したReactプロジェクト(TypeScript推奨)
  • VS Code(推奨)
  • React Developer Tools(ブラウザ拡張機能)

前提知識

  • 関数コンポーネントの基本
  • useState・useEffectの使い方
  • useRef・useMemo・useCallbackの基礎
  • Propsによるデータの受け渡し

Reactの再レンダリングの仕組み

パフォーマンス最適化を行う前に、Reactがいつ・なぜ再レンダリングを行うのかを正確に理解することが重要です。

再レンダリングが発生するタイミング

Reactのコンポーネントが再レンダリングされるのは、以下の3つのケースです。

  1. State(状態)が変更されたとき: useStateのsetter関数が呼ばれ、値が変更された場合
  2. Props(プロパティ)が変更されたとき: 親コンポーネントから渡されるpropsが変わった場合
  3. 親コンポーネントが再レンダリングされたとき: 親が再レンダリングされると、子も再レンダリングされる

特に3番目のケースが重要です。Reactでは、親コンポーネントが再レンダリングされると、propsが変更されていなくてもすべての子コンポーネントが再レンダリングされます。

再レンダリングの連鎖を理解する

以下の例で、再レンダリングの連鎖を確認してみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { useState } from 'react';

function App() {
  const [count, setCount] = useState(0);
  console.log('App rendered');

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <Header />
      <Content />
      <Footer />
    </div>
  );
}

function Header() {
  console.log('Header rendered');
  return <header>Header</header>;
}

function Content() {
  console.log('Content rendered');
  return <main>Content</main>;
}

function Footer() {
  console.log('Footer rendered');
  return <footer>Footer</footer>;
}

export default App;

ボタンをクリックしてcountを更新すると、AppだけでなくHeaderContentFooterもすべて再レンダリングされます。これらのコンポーネントはpropsを受け取っておらず、表示内容も変わらないにもかかわらずです。

再レンダリング ≠ DOMの更新

重要な点として、「再レンダリング」と「DOMの更新」は異なります。Reactは仮想DOMを使って差分を計算し、実際のDOMへの変更は最小限に抑えます。しかし、再レンダリング自体にもコストがかかります。

  • コンポーネント関数の実行
  • JSXの生成
  • 仮想DOMの比較(Reconciliation)

コンポーネントが軽量であれば問題になりませんが、複雑な計算やネストが深いコンポーネントツリーでは、パフォーマンスに影響を与える可能性があります。

React DevToolsでパフォーマンスを分析する

最適化を行う前に、まず問題を特定することが重要です。React DevToolsのProfilerを使って、どのコンポーネントが再レンダリングされているかを可視化できます。

React DevToolsのインストール

React DevToolsは、主要ブラウザの拡張機能として提供されています。

インストール後、Reactアプリケーションを開くと、開発者ツールに「Components」と「Profiler」タブが追加されます。

Profilerタブの使い方

Profilerタブでは、レンダリングのパフォーマンスを記録・分析できます。

  1. 記録の開始: 青い丸ボタンをクリックして記録を開始
  2. アプリの操作: 分析したい操作を実行
  3. 記録の停止: 同じボタンをクリックして停止
  4. 結果の確認: Flamegraph(炎のグラフ)でレンダリング時間を確認

Flamegraphでは、各コンポーネントのレンダリング時間が色と幅で表示されます。

  • 灰色: レンダリングされなかったコンポーネント
  • 青〜黄〜オレンジ: レンダリング時間が長いほど暖色系

Highlight updates機能

Componentsタブの歯車アイコンから「Highlight updates when components render」を有効にすると、再レンダリングされたコンポーネントが画面上でハイライト表示されます。これにより、不要な再レンダリングを視覚的に確認できます。

Profilerコンポーネントによるプログラマティックな計測

React DevToolsだけでなく、コード内で<Profiler>コンポーネントを使用して、レンダリングパフォーマンスをプログラマティックに計測することもできます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { Profiler, ProfilerOnRenderCallback } from 'react';

const onRender: ProfilerOnRenderCallback = (
  id,
  phase,
  actualDuration,
  baseDuration,
  startTime,
  commitTime
) => {
  console.log({
    id,           // Profilerのid
    phase,        // "mount" | "update" | "nested-update"
    actualDuration, // 実際のレンダリング時間(ms)
    baseDuration,   // メモ化なしの推定時間(ms)
    startTime,      // レンダリング開始時刻
    commitTime      // コミット時刻
  });
};

function App() {
  return (
    <Profiler id="App" onRender={onRender}>
      <MyComponent />
    </Profiler>
  );
}

actualDurationbaseDurationを比較することで、メモ化による最適化効果を測定できます。

React.memoでコンポーネントをメモ化する

memoは、コンポーネントをラップして、propsが変更されない限り再レンダリングをスキップするHOC(Higher-Order Component)です。

memoの基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import { memo } from 'react';

interface GreetingProps {
  name: string;
}

const Greeting = memo(function Greeting({ name }: GreetingProps) {
  console.log('Greeting rendered');
  return <h1>Hello, {name}!</h1>;
});

export default Greeting;

これにより、Greetingコンポーネントはnameプロパティが変更された場合のみ再レンダリングされます。

実践例: 不要な再レンダリングを防ぐ

先ほどの例をmemoで最適化してみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { useState, memo } from 'react';

function App() {
  const [count, setCount] = useState(0);
  console.log('App rendered');

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <Header />
      <Content />
      <Footer />
    </div>
  );
}

const Header = memo(function Header() {
  console.log('Header rendered');
  return <header>Header</header>;
});

const Content = memo(function Content() {
  console.log('Content rendered');
  return <main>Content</main>;
});

const Footer = memo(function Footer() {
  console.log('Footer rendered');
  return <footer>Footer</footer>;
});

export default App;

これで、countを更新してもHeaderContentFooterは再レンダリングされなくなります。

memoが効かないケース

memoを使っていても再レンダリングが発生するケースがあります。

オブジェクトや配列をpropsとして渡す場合

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
function App() {
  const [count, setCount] = useState(0);
  
  // 毎回新しいオブジェクトが作成される
  const user = { name: 'John', age: 30 };
  
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      {/* userは毎回新しい参照なのでmemoが効かない */}
      <UserCard user={user} />
    </div>
  );
}

const UserCard = memo(function UserCard({ user }: { user: { name: string; age: number } }) {
  console.log('UserCard rendered');
  return <div>{user.name} ({user.age})</div>;
});

この場合、userオブジェクトは毎回新しい参照になるため、Object.is比較で「異なる」と判定され、UserCardは毎回再レンダリングされます。

関数をpropsとして渡す場合

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
function App() {
  const [count, setCount] = useState(0);
  
  // 毎回新しい関数が作成される
  const handleClick = () => {
    console.log('clicked');
  };
  
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      {/* handleClickは毎回新しい参照なのでmemoが効かない */}
      <Button onClick={handleClick} />
    </div>
  );
}

const Button = memo(function Button({ onClick }: { onClick: () => void }) {
  console.log('Button rendered');
  return <button onClick={onClick}>Click me</button>;
});

これらの問題を解決するために、useMemouseCallbackを使用します。

useMemoで値をメモ化する

useMemoは、計算結果をメモ化し、依存配列の値が変更されない限り再計算をスキップします。

useMemoの基本構文

1
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

実践例1: 重い計算のメモ化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { useState, useMemo } from 'react';

function ExpensiveCalculation({ numbers }: { numbers: number[] }) {
  // 重い計算をメモ化
  const sum = useMemo(() => {
    console.log('Calculating sum...');
    return numbers.reduce((acc, num) => acc + num, 0);
  }, [numbers]);

  return <div>Sum: {sum}</div>;
}

function App() {
  const [count, setCount] = useState(0);
  const [numbers] = useState([1, 2, 3, 4, 5]);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        Count: {count}
      </button>
      <ExpensiveCalculation numbers={numbers} />
    </div>
  );
}

numbersが変わらない限り、countを更新しても計算は再実行されません。

実践例2: オブジェクトのメモ化でmemoを有効にする

先ほどの問題を解決するために、useMemoでオブジェクトをメモ化します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useState, useMemo, memo } from 'react';

function App() {
  const [count, setCount] = useState(0);
  
  // useMemoでオブジェクトをメモ化
  const user = useMemo(() => ({ name: 'John', age: 30 }), []);
  
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        Count: {count}
      </button>
      <UserCard user={user} />
    </div>
  );
}

const UserCard = memo(function UserCard({ user }: { user: { name: string; age: number } }) {
  console.log('UserCard rendered');
  return <div>{user.name} ({user.age})</div>;
});

useMemoによりuserの参照が維持されるため、memoが正しく機能します。

useMemoを使うべきかの判断基準

React公式ドキュメントでは、以下の場合にuseMemoの使用を推奨しています。

  1. 計算コストが高い場合: 配列のフィルタリング、ソート、大量データの変換など
  2. memoでラップしたコンポーネントにオブジェクトを渡す場合: 参照の同一性を保つため
  3. useEffectの依存配列に含まれる値をメモ化したい場合: 不要なEffect実行を防ぐため

一方、単純な計算や文字列の結合程度であれば、useMemoのオーバーヘッドの方が大きくなる可能性があります。

useCallbackで関数をメモ化する

useCallbackは、関数をメモ化し、依存配列の値が変更されない限り同じ関数参照を返します。

useCallbackの基本構文

1
2
3
4
5
6
const memoizedCallback = useCallback(
  () => {
    doSomething(a, b);
  },
  [a, b]
);

実践例: コールバック関数のメモ化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { useState, useCallback, memo } from 'react';

function App() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  // useCallbackで関数をメモ化
  const handleClick = useCallback(() => {
    console.log('Button clicked');
  }, []);

  const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  }, []);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        Count: {count}
      </button>
      <ExpensiveInput value={text} onChange={handleChange} />
      <ExpensiveButton onClick={handleClick} />
    </div>
  );
}

const ExpensiveInput = memo(function ExpensiveInput({
  value,
  onChange
}: {
  value: string;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
  console.log('ExpensiveInput rendered');
  return <input value={value} onChange={onChange} />;
});

const ExpensiveButton = memo(function ExpensiveButton({
  onClick
}: {
  onClick: () => void;
}) {
  console.log('ExpensiveButton rendered');
  return <button onClick={onClick}>Click me</button>;
});

useCallbackにより関数の参照が維持されるため、countを更新してもExpensiveButtonは再レンダリングされません。

useMemoとuseCallbackの関係

実は、useCallbackuseMemoの特殊なケースです。以下の2つは等価です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// useCallback
const handleClick = useCallback(() => {
  console.log('clicked');
}, []);

// useMemoで同じことを実現
const handleClick = useMemo(() => {
  return () => {
    console.log('clicked');
  };
}, []);

関数をメモ化する場合は、より簡潔なuseCallbackを使用しましょう。

コード分割とLazy Loadingで初期読み込みを最適化

大規模なアプリケーションでは、すべてのコードを一度に読み込むと初期表示が遅くなります。lazySuspenseを使用してコード分割を行い、必要なときに必要なコンポーネントだけを読み込むことで、初期読み込みを高速化できます。

lazyとSuspenseの基本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import { lazy, Suspense } from 'react';

// 動的インポートでコンポーネントを遅延読み込み
const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <div>
      <h1>My App</h1>
      <Suspense fallback={<div>Loading...</div>}>
        <HeavyComponent />
      </Suspense>
    </div>
  );
}

lazyimport()を使用した動的インポートを返す関数を受け取り、Suspenseはコンポーネントの読み込み中に表示するフォールバックUIを指定します。

実践例: ルートベースのコード分割

React Routerと組み合わせて、ページごとにコード分割を行う例です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';

// 各ページを遅延読み込み
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));

function App() {
  return (
    <BrowserRouter>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/about">About</Link>
        <Link to="/dashboard">Dashboard</Link>
      </nav>

      <Suspense fallback={<div>Loading page...</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/dashboard" element={<Dashboard />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

export default App;

これにより、各ページのコードは必要になったときにのみ読み込まれます。

条件付きレンダリングでの遅延読み込み

特定の条件でのみ表示されるコンポーネントを遅延読み込みする例です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { lazy, Suspense, useState } from 'react';

// 重いモーダルコンポーネントを遅延読み込み
const HeavyModal = lazy(() => import('./HeavyModal'));

function App() {
  const [showModal, setShowModal] = useState(false);

  return (
    <div>
      <button onClick={() => setShowModal(true)}>
        Open Modal
      </button>

      {showModal && (
        <Suspense fallback={<div>Loading modal...</div>}>
          <HeavyModal onClose={() => setShowModal(false)} />
        </Suspense>
      )}
    </div>
  );
}

モーダルを開くまで、HeavyModalのコードは読み込まれません。

lazyの注意点

lazyを使用する際の注意点があります。

  1. コンポーネントの外で宣言する: コンポーネント内でlazyを呼び出すと、再レンダリングのたびに新しいコンポーネントが作成されます
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 悪い例: コンポーネント内で宣言
function App() {
  // 再レンダリングのたびに新しいlazyコンポーネントが作成される
  const HeavyComponent = lazy(() => import('./HeavyComponent'));
  return <HeavyComponent />;
}

// 良い例: コンポーネントの外で宣言
const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return <HeavyComponent />;
}
  1. default exportが必要: lazydefaultエクスポートを期待します

パフォーマンス最適化のベストプラクティス

最適化の優先順位

  1. まず計測する: React DevToolsで問題箇所を特定してから最適化を行う
  2. 早すぎる最適化を避ける: 実際にパフォーマンス問題が発生してから対処する
  3. アーキテクチャレベルの改善を優先: 個別のメモ化より、状態管理の設計を見直す

やるべきこと

  • 状態を必要最小限のコンポーネントに配置する: 状態を持つコンポーネントの範囲を限定する
  • リストにはkeyを適切に設定する: インデックスではなく一意のIDを使用する
  • 重い計算はuseMemoでメモ化する: フィルタリング、ソート、変換処理など
  • コールバック関数はuseCallbackでメモ化する: memoでラップした子コンポーネントに渡す場合

やってはいけないこと

  • すべてのコンポーネントをmemoでラップする: オーバーヘッドが発生する
  • すべての値をuseMemoでメモ化する: 単純な計算には不要
  • 依存配列を空にして問題を回避する: バグの原因になる
  • 計測なしで最適化を行う: 効果がない、または逆効果の場合がある

React Compilerについて

React 19では、React Compilerが導入されました。このコンパイラは、memouseMemouseCallbackと同等の最適化を自動的に適用します。将来的には、手動でのメモ化が不要になる可能性があります。

ただし、React Compilerを使用する場合でも、本記事で解説した再レンダリングの仕組みを理解しておくことは重要です。最適化がどのように機能するかを知ることで、より良いコンポーネント設計ができるようになります。

まとめ

本記事では、Reactパフォーマンス最適化について、以下のポイントを解説しました。

  • 再レンダリングの仕組み: 状態変更、props変更、親の再レンダリングで発生
  • React DevTools: Profilerで問題箇所を特定
  • memo: propsが変わらなければ再レンダリングをスキップ
  • useMemo: 計算結果やオブジェクトをメモ化
  • useCallback: 関数をメモ化
  • lazy + Suspense: コード分割で初期読み込みを高速化

パフォーマンス最適化は、「計測」→「問題特定」→「最適化」→「再計測」のサイクルで行うことが重要です。闇雲にmemouseMemoを追加するのではなく、実際にボトルネックとなっている箇所を見つけてから対処しましょう。

次の記事では、Reactアプリケーションを本番環境にデプロイする方法について解説します。VercelやNetlifyを使った無料公開の手順を学び、作成したアプリを世界に公開しましょう。

次に読む記事

参考リンク