【React】Reactアプリのパフォーマンスチューニング入門

JavaScript

こんにちは、かつコーチです。

前回は「React DevToolsでコンポーネントの状態を確認する方法」を解説しました。

今回は、DevToolsのProfilerタブで見つけた「重い箇所」を実際にどう改善していくか、パフォーマンスチューニングの基本手法をまとめます。

React.memouseMemouseCallbackは名前だけ知っている人も多いと思いますが、「いつ使うべきか」を正しく理解していないと、逆効果になることもある機能です。

エラー解決編の締めくくりとして、この記事でしっかり整理していきましょう。

パフォーマンスチューニングを始める前に

計測せずに最適化しない

最初に強調しておきたいのが、「なんとなく重そうだから」という理由で最適化を始めないことです。

前回紹介したProfilerタブなどで実際に計測し、「どのコンポーネントの、何が原因で遅いのか」を特定してから対処するのが鉄則です。

これは早すぎる最適化(Premature Optimization)と呼ばれる問題で、必要のない箇所を最適化するとコードが複雑になるだけで、実際の体感速度はほとんど変わらないことが多いです。

Reactが遅くなる典型パターン

計測してみると、Reactアプリが遅くなる原因はだいたい次のどれかに集約されます。

  • 本来更新の必要がないコンポーネントまで再レンダリングされている
  • 重い計算処理を、レンダリングのたびに毎回やり直している
  • 巨大なリストを一度に全部描画している
  • 初期表示に必要ないコードまで最初にまとめて読み込んでいる

それぞれに対応する解決策を見ていきます。

不要な再レンダリングを防ぐ:React.memo

基本の使い方

React.memoは、propsが変化していなければコンポーネントの再レンダリングをスキップしてくれる機能です。

import { memo } from "react";

type ItemProps = {
  label: string;
};

const ListItem = memo(function ListItem({ label }: ItemProps) {
  console.log("rendered:", label);
  return <li>{label}</li>;
});

親コンポーネントが再レンダリングされても、ListItemに渡すlabelが変化していなければ、ListItem自体の再レンダリングはスキップされます。

React.memoが効かないケースに注意する

React.memoは、propsを浅い比較(shallow compare)で判定します。

つまり、オブジェクトや関数をpropsとして渡している場合、中身が同じでも参照が違えば「変化した」と判定されてしまいます。

❌ Before:関数をそのままpropsに渡してmemoが効かない

function ItemList({ items }: { items: { id: string; label: string }[] }) {
  return (
    <ul>
      {items.map((item) => (
        // レンダリングのたびに新しい関数が作られ、ListItemのmemoが効かない
        <ListItem key={item.id} label={item.label} onClick={() => console.log(item.id)} />
      ))}
    </ul>
  );
}

✅ After:useCallbackで関数の参照を安定させる

import { useCallback } from "react";

function ItemList({ items }: { items: { id: string; label: string }[] }) {
  const handleClick = useCallback((id: string) => {
    console.log(id);
  }, []);

  return (
    <ul>
      {items.map((item) => (
        <ListItem key={item.id} label={item.label} onClick={handleClick} />
      ))}
    </ul>
  );
}

useCallbackで関数をメモ化し、参照を安定させることで、ListItem側のReact.memoが正しく機能するようになります。

React.memouseCallbackはセットで使ってはじめて効果を発揮する、と覚えておくとよいです。

重い計算を使い回す:useMemo

計算コストが高い処理をキャッシュする

useMemoは、依存する値が変わらない限り、計算結果をキャッシュして使い回すためのフックです。

import { useMemo, useState } from "react";

function ProductSummary({ products }: { products: { price: number }[] }) {
  const [theme, setTheme] = useState<"light" | "dark">("light");

  const totalPrice = useMemo(() => {
    console.log("集計処理を実行");
    return products.reduce((sum, p) => sum + p.price, 0);
  }, [products]);

  return (
    <div>
      <p>合計金額: {totalPrice}円</p>
      <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
        テーマ切り替え
      </button>
    </div>
  );
}

themeを切り替えるだけの操作ではproductsが変わらないため、totalPriceの再計算はスキップされます。

配列の集計処理のように件数が多いほど重くなる処理には、特に効果があります。

かつコーチが実際にハマった「useMemoの使いすぎ」

私が実際にやってしまった失敗は、単純な足し算や文字列の連結のような「一瞬で終わる処理」にまでuseMemoをつけまくったことです。

チームの別メンバーからコードレビューで「これ、useMemoのオーバーヘッド(依存配列の比較コスト)の方が、計算自体のコストより高いのでは」と指摘され、初めて自分の判断が間違っていたことに気づきました。

useMemo自体にも「前回の依存配列と比較する」というコストがかかります。

軽い計算にまで乱用すると、コードが読みにくくなるだけでなく、かえってパフォーマンスが落ちることさえあります。

❌ Before:軽い処理にまでuseMemoをつけてしまう

function PriceLabel({ price }: { price: number }) {
  const label = useMemo(() => `${price}円`, [price]); // ただの文字列連結にuseMemoは不要
  return <span>{label}</span>;
}

✅ After:計算コストが高い処理だけに限定する

function PriceLabel({ price }: { price: number }) {
  const label = `${price}円`;
  return <span>{label}</span>;
}

useMemoは「計算コストが体感できるほど高い処理」「Profilerで実際にボトルネックだと確認できた処理」に絞って使うのが正解です。

「とりあえずuseMemoで囲む」という考え方自体を見直すきっかけになった出来事でした。

巨大なリストを描画する:仮想化(Virtualization)

画面に見えている分だけ描画する考え方

数百〜数千件のリストをそのままmapで描画すると、画面に表示されていない項目まですべてDOMに存在することになり、初期描画が重くなります。

仮想化(Virtualization)は、画面に見えている範囲の項目だけを実際に描画し、スクロールに合わせて表示する項目を入れ替える手法です。

react-windowreact-virtuosoといったライブラリを使うと、この仕組みを自前で実装せずに導入できます。

import { FixedSizeList } from "react-window";

function BigList({ items }: { items: string[] }) {
  return (
    <FixedSizeList height={400} width={300} itemCount={items.length} itemSize={35}>
      {({ index, style }) => <div style={style}>{items[index]}</div>}
    </FixedSizeList>
  );
}

数千件のデータであっても、実際にDOMに存在するのは画面に表示されている数十件程度になるため、初期描画が大幅に速くなります。

初期表示を軽くする:コード分割

lazyとSuspenseで必要になるまで読み込みを遅らせる

すべてのコンポーネントを最初にまとめて読み込むと、初期表示までの時間(初回ロード時間)が伸びてしまいます。

React.lazySuspenseを使うと、そのコンポーネントが実際に必要になったタイミングで読み込むように分割できます。

import { lazy, Suspense } from "react";

const AdminDashboard = lazy(() => import("./AdminDashboard"));

function App() {
  return (
    <Suspense fallback={<p>読み込み中...</p>}>
      <AdminDashboard />
    </Suspense>
  );
}

管理画面や設定画面のように「一部のユーザーしか使わない機能」から優先的にコード分割の対象にすると、効果を実感しやすいです。

まとめ

この記事のポイント

  • 最適化は必ずProfilerなどで計測してから、ボトルネックを特定して行う
  • React.memouseCallbackとセットで使わないと効果を発揮しないことが多い
  • useMemoは計算コストが高い処理に限定し、軽い処理への乱用は避ける
  • 大量データのリストは仮想化、初期表示はReact.lazyによるコード分割で軽くできる

次に読むべき記事

エラー解決編はここまでです。

次回からは「設計・アーキテクチャ」編として、「太らせないコンポーネント設計の考え方」を解説していきます。

→ 次の記事:太らせないコンポーネント設計の考え方

タイトルとURLをコピーしました