← Blog
·11 blog.minutes

Reactパフォーマンス最適化の完全ガイド

memoからサーバーコンポーネントまで——Reactアプリを過剰エンジニアリングせずに高速化する実践的なガイド。

Reactのパフォーマンス最適化には評判の問題がある。10人の開発者にReactアプリを高速化する方法を尋ねると、8人は「useMemoとuseCallbackをすべてに使え」と言うだろう。残りの2人は「時期尚早な最適化は諸悪の根源だ」と言って立ち去るだろう。両方のグループが間違っており、両方のグループが不必要に複雑または不必要に遅いアプリを出荷している。

現実はより微妙である。Reactのレンダリングモデルはほとんどのケースでそのまま効率的だが、うまく機能しないよく理解されたパターンが存在する。このガイドでは、すべての重要なReact最適化テクニック——それらがどのように機能するか、いつ役立つか、そして重要なことに、いつ害になるか——を扱う。目標は、あらゆる場所でmemoを使うようにすることではない。あらゆる状況で正しい選択が明らかになるパフォーマンスのメンタルモデルを提供することである。

Reactが再レンダリングするタイミングを理解する

何かを最適化する前に、再レンダリングの原因を理解する必要がある。Reactはコンポーネントの状態が変わったとき、親が再レンダリングしたとき、またはコンポーネントが使用するコンテキスト値が変わったときに再レンダリングする。シンプルに聞こえるが、カスケード効果にパフォーマンス問題が隠れている。

親コンポーネントが再レンダリングすると、デフォルトですべての子も再レンダリングされる——子のpropsが変更されていなくても。Reactは、子のレンダリング関数を実行せずに子が親の状態に依存しているかどうかを知ることができないため、これを行う。これはバグではなく、Reactの調整モデルをシンプルで予測可能に保つ設計上の選択である。しかし、高レベルのコンポーネントの状態変更が、数十または数百の子孫にわたって再レンダリングをトリガーする可能性があることを意味する。

重要な洞察は、再レンダリングとDOMの更新は同じではないということである。Reactは仮想DOM出力(JSX)を前回の出力と比較し、差分のみを実際のDOMにコミットする。コンポーネントはDOMミューテーションなしで千回再レンダリングできる。コストはJavaScriptの実行——仮想DOMオブジェクトの作成、フックの実行、ツリーの差分比較——にある。小さなコンポーネントツリーではこのコストは無視できる。大きなリスト、複雑なグラフ、または高価な計算を行うコンポーネントでは、すぐに積み重なる。

// Every keystroke in this input re-renders the entire tree
function SearchPage() {
  const [query, setQuery] = useState("");

  return (
    <div>
      <SearchInput value={query} onChange={setQuery} />
      <SearchResults query={query} />
      <Sidebar>
        <FilterPanel />
        <RecentSearches />
      </Sidebar>
    </div>
  );
}

// Without optimization, FilterPanel and RecentSearches
// re-render on every keystroke, even though nothing
// they depend on has changed.

パフォーマンス調査の最初のステップは、実際に問題があるかどうかを特定することである。時期尚早な最適化は測定可能なメリットなしに複雑さを追加する。プロファイルが先、最適化は後——しかし、ボトルネックを特定した場合、このガイドのテクニックが使うべきツールである。

メモ化:memo、useMemo、useCallback

メモ化は最も議論されるReact最適化テクニックであり、最も誤用されている。中核となる考え方は単純だ:関数が同じ入力に対して同じ出力を生成する場合、結果をキャッシュし、後続の呼び出しでは計算をスキップする。Reactは3つのメモ化ツールを提供し、それぞれ異なる目的を果たす。

React.memoはコンポーネントをラップし、propsが変更されていない場合(浅い比較を使用)の再レンダリングを防ぐ。同じpropsを頻繁に受け取るリーフコンポーネント——リスト項目、チャート要素、またはレンダリング出力がpropsのみに依存する純粋コンポーネント——に最も効果的である。

import { memo } from "react";

const ExpenseRow = memo(function ExpenseRow({ label, amount }: {
  label: string;
  amount: number;
}) {
  return (
    <tr>
      <td>{label}</td>
      <td className={amount < 0 ? "text-red-500" : "text-green-500"}>
        ${amount.toFixed(2)}
      </td>
    </tr>
  );
});

// Now ExpenseRow only re-renders when label or amount changes.
// Without memo, it re-renders every time the parent re-renders.

useMemoはレンダリング間での計算結果をキャッシュする。コンポーネントがすべてのレンダリングで高価な計算——大きな配列のフィルタリング、データのフォーマット、複雑な変換の実行——を行う場合に有用である。useMemoがないと、その作業は入力が変更されていなくてもすべての再レンダリングで繰り返される。

function Dashboard({ transactions, filter }: Props) {
  // Without useMemo: this runs on every render
  const visibleTransactions = useMemo(
    () => transactions
      .filter(t => t.date >= filter.start && t.date <= filter.end)
      .sort((a, b) => b.amount - a.amount),
    [transactions, filter]
  );

  // Without useMemo, filtering and sorting 10,000 items
  // happens on every keystroke in any input on the page.
  return <TransactionList items={visibleTransactions} />;
}

useCallbackはuseMemoと同じだが、関数用である。依存関係が変更されたときにのみ変更されるコールバック関数のメモ化バージョンを返す。これは、レンダーボディで定義された関数はすべてのレンダリングで再作成され、memoが使用する浅い比較を壊すため重要である。

ほとんどのガイドが間違っている重要なルールをここに示す:すべてをuseMemoとuseCallbackでラップしない。各メモ化呼び出しにはコストがある——依存関係配列の保存、すべてのレンダリングでの比較、クロージャメモリの割り当て。安価な操作にメモ化を適用すると、アプリが遅くなり、速くはならない。ヒューリスティックは単純だ:問題を測定した場合、または計算が明らかに高価な場合(複雑なデータ変換、再帰的操作、大規模リストレンダリング)にのみメモ化する。

最も高価なメモ化は、書かれるべきではなかったものである。プロファイルが先、ラップは後。すべての抽象化にはコストがあり、メモ化はレンダリングサイクル上の抽象化である。

一般的なアンチパターンは、防御的にすべてをmemoでラップし、パフォーマンス問題を防ごうとすることである。それは機能しない。すべてのレンダリング比較にオーバーヘッドを追加し、余分なコードでバンドルを膨らませ、コードベースの推論を難しくする。デフォルトパターンとしてではなく、プロファイリングが役立つことを確認した後に対象を絞った最適化としてメモ化を追加する。

React.lazyとSuspenseによるコード分割

バンドルサイズはReactアプリケーションで最も見落とされているパフォーマンスの次元である。開発者はレンダリング最適化に執着する一方で、すべてのユーザーが何かを見る前にダウンロード、解析、実行しなければならない500KBのJavaScriptを出荷している。コード分割はこれを解決する。バンドルをオンデマンドでロードされるチャンクに分割する。

React.lazyを使用すると、動的にインポートされたコンポーネントを通常のコンポーネントのようにレンダリングできる。Suspenseと組み合わせることで、チャンクのロード中にフォールバックUIを表示できる。パフォーマンス上のメリットは2つある:初期バンドルが小さくなるためページのロードが速くなり、ユーザーは実際に使用するコードに対してのみ支払う。

import { lazy, Suspense } from "react";

const AnalyticsDashboard = lazy(
  () => import("./AnalyticsDashboard")
);
const DataExportPanel = lazy(
  () => import("./DataExportPanel")
);

function App() {
  const [showAnalytics, setShowAnalytics] = useState(false);

  return (
    <div>
      <button onClick={() => setShowAnalytics(true)}>
        View Analytics
      </button>
      <Suspense fallback={<div>Loading...</div>}>
        {showAnalytics && <AnalyticsDashboard />}
      </Suspense>
    </div>
  );
}

// AnalyticsDashboard and its dependencies are only loaded
// when the user clicks the button, not on initial page load.

遅延ロードの最良の候補は、ルートレベルのコンポーネント、重い可視化ライブラリ(チャート、グラフ、マップ)、リッチテキストエディタ、および初期レンダリングで表示されないかユーザー操作によってトリガーされるコンポーネントである。経験則:コンポーネントがバンドルに20KB以上追加し、初期レンダリングで表示されない場合、おそらく遅延ロードすべきである。

React 19の改善されたSuspense動作により、コード分割はさらに実用的になった。Suspense境界内のデータフェッチはレンダリングライフサイクルと完全に統合され、最初にチャンクをロードしてからデータをフェッチするというウォーターフォール問題を排除した。Promiseを読み取るための新しいuse()フックと組み合わせることで、ローディング状態間の境界がよりクリーンで合成可能になる。

仮想スクロールとリスト最適化

大きなリストのレンダリングは、実際のReactアプリケーションで最も一般的なパフォーマンス問題である。1万項目のリストは開発では問題なく動き、本番で崩壊する。なぜならReactはすべてのレンダリングで1万の仮想DOMノードを作成し調整する必要があるからだ。ブラウザのレイアウトエンジンはその後、1万のDOMノードの位置を計算しなければならない。結果はフリーズしたUIとイライラしたユーザーである。

仮想スクロールは、ビューポートに表示されているアイテムと、その上下の小さなバッファのみをレンダリングすることでこれを解決する。ユーザーがスクロールすると、ビューポート外のアイテムはアンマウントされ、新しいアイテムがマウントされる。DOMは小さく保たれ(通常、リストサイズに関係なく20〜30ノード)、Reactの調整コストは一定に保たれる。

import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";

function VirtualList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48, // estimated row height
    overscan: 5, // render 5 extra items off-screen
  });

  return (
    <div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
      <div style={{ height: virtualizer.getTotalSize() }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: "absolute",
              top: 0,
              transform: `translateY(${virtualItem.start}px)`,
              height: virtualItem.size,
              width: "100%",
            }}
          >
            <ItemRenderer item={items[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

// Only ~25-30 rows are rendered regardless of list size.
// 100,000 items? Same render cost.

仮想スクロール以外にも、リストのパフォーマンスは安定したキーに依存する。配列インデックスをキーとして使用すると、Reactがどのアイテムが変更されたかを誤認し、不必要なDOMノードのアンマウントと再マウントが発生する。データから一意の識別子(アイテムのID)をキーとして使用する。これにより、アイテムが並べ替えられたり、挿入されたり、削除されたりしたときにReactがDOMノードを再利用でき、破壊と再作成よりもはるかに安価である。

  • 200〜500項目を超える可能性があるリストには仮想スクロールを使用する。閾値はアイテムの複雑さによるが、中程度の複雑さの500行でガタつきに気づき始める。
  • データから派生した安定した一意のキー(アイテムID)を常に使用する。インデックスキーは、変更されない静的リストの最後の手段である。
  • リストアイテムコンポーネントは軽量に保つ。各アイテムは最小限のフック呼び出しを持つシンプルなプレゼンテーションコンポーネントにする。リストアイテム内の高価な計算は、レンダリングされるすべての行にわたって増幅される。
  • すべてのアイテムを一度に表示する必要がないリストには、ウィンドウ化またはページネーションを検討する。最善の最適化は、何かをまったくレンダリングしないことである。

プロファイリング、バンドル分析、React 19+のパターン

最も重要なパフォーマンスツールは、ライブラリやフックではなく、React DevToolsプロファイラである。何かを最適化する前に、遅い動作を再現しながらプロファイリングセッションを記録する。プロファイラは、どのコンポーネントがレンダリングされたか、なぜレンダリングされたか(props変更、state変更、context変更、親の再レンダリング)、各レンダリングにかかった時間を正確に示す。このデータなしでは推測しているに過ぎない。

バンドル分析は2つ目の重要なツールである。800KBのJavaScriptを出荷する高速なReactアプリは、ブラウザがそれをダウンロードして解析しなければならないため、依然として遅い。vite-bundle-visualizer、statoscope、source-map-explorerなどのツールはバンドルの視覚的表現を生成し、どのパッケージが最も多くのバイトを占めているかを示す。結果はしばしば驚くべきものである——単一の大きな依存関係(moment.js、チャートライブラリ、アイコンセット)がバンドルの半分を占めることがある。

React 19はパフォーマンス最適化の状況を変えるパターンを導入する。サーバーコンポーネントはサーバー上で実行され、レンダリングされたHTMLのみをクライアントに送信する。これにより、インタラクティビティを必要としないコンポーネントのクライアント側JavaScriptが排除される。これはニッチな最適化ではない——データをフェッチしてクライアント側の状態やイベントハンドラなしでレンダリングするコンポーネントはすべてサーバーコンポーネントにすべきである。クライアントに送信されていたであろうJavaScriptはゼロになる。

サーバーコンポーネントとクライアントコンポーネントの区別は、パフォーマンスの新しいメンタルモデルを生み出す。memoに手を伸ばす前に、自問する:このコンポーネントはクライアントで実行する必要があるか?答えが「いいえ」なら、すべての再レンダリングコスト、すべてのハイドレーションコスト、そのコンポーネントがバンドルに貢献していたであろうすべてのJavaScriptのバイトを排除したことになる。サーバーコンポーネントはReactのクライアント側最適化ツールの代替ではない。それらはより高レベルの戦略であり、コンポーネントツリーの大部分に対してクライアント側の最適化を不要にする。

画像最適化は、フレームワークが開発者が手動で行っていたことを処理するもう1つの領域である。Next.jsのImageコンポーネントは、正しいサイズとフォーマットのレスポンシブ画像を自動的に提供し、スクロールしないと見えない画像を遅延ロードし、画像がロードされる前にスペースを確保することでレイアウトシフトを防ぐ。プレーンなimgタグと大きなJPEGをまだ使用している場合、この単一の変更は、どのメモ化戦略よりも大きなパフォーマンス改善をもたらすことが多い。

  • アプリ内の最も遅いインタラクションでReact DevToolsプロファイラを実行し、変更を加える前にベースラインとして記録を保存する。
  • vite-bundle-visualizerまたはsource-map-explorerでバンドルを分析する。より軽量な代替品と交換できる大きな依存関係を探す。
  • React 19をサーバーフレームワークで使用している場合、データフェッチコンポーネントをサーバーコンポーネントに変換する。これによりクライアント側のコスト全体が排除される。
  • imgタグをフレームワークのImageコンポーネント(next/imageなど)に置き換え、自動レスポンシブ画像と遅延ロードを実現する。
  • Chrome DevToolsのパフォーマンスタブを使用して、Reactのレンダリング時間だけでなく、インタラクション遅延、レイアウトシフト、ロングタスクを測定する。

すべてをまとめる

Reactのパフォーマンス最適化には明確な階層がある。最上位はアーキテクチャである:サーバー上で実行されるサーバーコンポーネント、バンドルを小さく保つコード分割、DOMサイズを制限する仮想スクロール。中間は対象を絞ったメモ化である:不必要に再レンダリングするリーフコンポーネントへのmemo、高価な計算へのuseMemo、安定したコールバック参照のためのuseCallback。最下位——そしてほとんどの人がここから始める——はマイクロ最適化である:インライン関数、オブジェクト参照、測定可能なメリットなしに複雑さを追加する時期尚早なラッピング。

上から下へ取り組む。まずプロファイルし、次にアーキテクチャを確認し、その後に対象を絞ったメモ化を適用する。マイクロ最適化から始めない。適切に最適化されたReactアプリと不適切に最適化されたReactアプリの違いは、適切な場所でmemoを使用したかどうかではほとんどない。それは、Reactが効率的に仕事をすることを可能にするアーキテクチャ——小さなコンポーネントツリー、小さなバンドル、小さなDOMサイズ、明確なデータフロー——があるかどうかである。

このガイドのテクニックは包括的だが、原則はシンプルである:Reactに与える作業を減らすことで、Reactのデフォルトの動作を高速にする。レンダリングするコンポーネントを減らす。出荷するJavaScriptを減らす。計算する値を減らす。Reactに求めることが少なければ少ないほど、高速になり——パフォーマンスについて考える必要も少なくなる。