iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

React Scanで再レンダーを改善してみた

はじめに

こんにちは、iimonエンジニアのみやこしです!今回は、Reactアプリのパフォーマンス改善に役立つツール React Scan を使って、実際の業務で発生したテーブルリストで「チェックボックスが重い問題」をどのように分析・改善したのかを紹介します。

使用技術

今回の検証では、以下の技術を使用しています。

  • React(関数コンポーネント + Hooks)

  • Ant Design(UIライブラリ)

React Scanとは

Reactアプリのパフォーマンス問題を可視化でき、自動で検出してくれるツールです。 特に不要な再レンダーの検出に特化しており、問題のあるコンポーネントを画面上で直接ハイライトして表示してくれます。 そのため、どこを修正すべきかが一目で分かります。

React Scanの導入

  • ローカル環境でアプリを確認する
npx react-scan@latest http://localhost:3000
  • scriptタグ

ビルド設定の変更が不要で、HTMLにscriptタグを1行追加するだけで有効になります 。他のスクリプトより前に読み込む必要があります。

<script crossOrigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js"></script>

React Scan使ってみる

今回は、実際の業務で発生した課題をもとに、不動産サイトを再現した物件情報テーブルを用いて検証を行いました。チェックボックスをクリックしてからチェックが反映されるまで非常に時間がかかるという問題があり、ログを出してみると1回のクリックで約1万回以上レンダリングが発生していました。どこが原因なのかを特定するために、React Scanを導入して検証してみました。

実際に導入してみると、画面を操作するたびにどのコンポーネントがレンダリングされているかわかります。

React Scanの分析

右下のReact Scanのツールバーをクリックすると、詳細な分析結果が表示されます。

① Clicked Wave / processing time

ヘッダーに表示される Clicked Wave 1792ms processing time は、ユーザー操作(今回の場合はチェックボックスのクリック)から処理完了までにかかった合計時間を示しています。 この数値が大きいほど、ユーザーが操作に対して「遅い」と感じる可能性が高くなります。

② History(FPS Drop)

左パネルの History には、操作中のFPSの変動が表示されます。 特に FPS Drop は、処理中にフレームレートがどこまで低下したかを示す指標です。 通常、滑らかなUIの目安は60FPSです。 これが大きく低下している場合、描画処理が詰まり、画面がカクついたりフリーズしたりしていることを意味します。

③ Rankedタブ(レンダリング負荷の可視化)

レンダリング回数の多い順にコンポーネントが並んでいます。最上部の JavaScript / React Hooks:1065ms は、約1065ms処理に費やされていることを意味します。その下を見ると、Typographyがx3000回、SingleObserverがx3000回、EllipsisMeasureがx3000回、Spaceがx1500回、Tagがx1000回、Tooltipがx500回、Checkboxがx501回と、1回のクリックで合計約12,500回以上の再レンダーが発生していることがわかります。

④ Memoizableタグ

コンポーネント名の横に表示される Memoizable は、 「このコンポーネントは React.memo でラップすることで不要な再レンダーを防げる可能性が高い」 とReact Scanが自動判定したものです。 これは最適化の優先候補を示すヒントになります。

通常値との比較

※通常値は一般的な目安であり、公式の基準値ではありません

指標 通常値(目安) 今回の検出値
処理時間 16ms以下(1フレーム内) 1600ms
再レンダー回数(1操作あたり) 変更のあるコンポーネントのみ 12,500回以上
Hooks処理時間割合 小さいことが望ましい 約66%(1065ms / 1600ms)
Memoizable検出数 0 数件

React Scanのバッジの色は処理時間の深刻度(目安)

意味 処理時間の目安
🟢 緑 良好 16ms以下
🟠 オレンジ 警告 16ms〜500ms程度
🔴 赤 危険 500ms以上

分析結果から改善

JavaScript/React Hooks

レンダリング負荷が最も大きい JavaScript / React Hooks から改善を進めていきます。

分析結果では、上位に表示されている AntdIcon が、1回のクリックで3002回もレンダリングされていることが確認できました。

これは、antdのアイコンコンポーネントが各行に複数配置されており、親コンポーネントが再レンダーされるたびに、すべてのアイコンが再生成されていることが原因であることがわかりました。

antdのアイコンは内部でSVGを動的に生成しているため、単純なテキスト要素と比較するとレンダリングコストが高くなります。 そのため、大量に再レンダーが発生すると、全体のパフォーマンスに大きな影響を与えます。その後に続いているものも全部Antdの Typography.Text が内部で使用しているコンポーネントです。 Antd系コンポーネントのレンダリングコストが今回のパフォーマンス低下の最大の要因となっっていることがわかりました。

AntdIconの改善

アイコンをコンポーネントの外で定数として定義し、毎回の再生成を防ぐするようにしました。

// ❌ 改善前:アイコンが毎レンダーで再生成される
function PropertyName({ property }) {
  const { copied, copy } = useCopyText();
  return (
    <td>
      <HomeOutlined style={{ fontSize: 16, color: "#2563eb" }} />
      <a>{property.name}</a>
      <span>{copied ? <CheckOutlined /> : <CopyOutlined />}</span>
      <span><BuildOutlined style={{ marginRight: 3 }} />{property.structure}</span>
      <span><CompassOutlined style={{ marginRight: 3 }} />{property.direction}</span>
    </td>
  );
}

// ✅ 改善後:アイコンをコンポーネント外で定数化
const HOME_ICON = <HomeOutlined style={{ fontSize: 16, color: "#2563eb" }} />;
const COPY_ICON = <CopyOutlined />;
const CHECK_ICON = <CheckOutlined />;
const BUILD_ICON = <BuildOutlined style={{ marginRight: 3 }} />;
const COMPASS_ICON = <CompassOutlined style={{ marginRight: 3 }} />;

function PropertyName({ property }) {
  const { copied, copy } = useCopyText();
  return (
    <td>
      {HOME_ICON}
      <a>{property.name}</a>
      <span>{copied ? CHECK_ICON : COPY_ICON}</span>
      <span>{BUILD_ICON}{property.structure}</span>
      <span>{COMPASS_ICON}{property.direction}</span>
    </td>
  );
}
その他のAntdのコンポーネントの改善

 単純な表示にしか使っていないAntdコンポーネントをネイティブHTML/CSSに置き換えることで、内部コンポーネントの連鎖的な再生成を防ぐようにしました。

例:

// ❌ 改善前:内部で1コンポーネント生成 × 約3000個
<Text strong style={{ fontSize: 13 }}>{property.rooms}</Text>
<Text type="secondary" style={{ fontSize: 11 }}>{property.size}m²</Text>

// ✅ 改善後
<span style={{ fontSize: 13, fontWeight: 600 }}>{property.rooms}</span>
<span style={{ fontSize: 11, color: "#94a3b8" }}>{property.size}m²</span>
改善結果

以上のことを改善することによって、Antd系のコンポーネント、アイコンが最適化の対象からなくなり、レンダリングの秒数がかなり減ったことがわかります、色も赤からオレンジに変わりましたね✨ しかしレンダリングの時間はまだ十分な高速とは言えません、ここからさらに改善できる部分があるので、引き続き見ていきましょう。

useCallback + React.memoでチェックボックスの改善

改善後も最適化の対象として挙げられている Checkbox が気になりました。 確認すると、Checkboxが501回レンダーされていることが分かります。 1行のチェックボックスをクリックしているにもかかわらず、全500行すべてがレンダリングされている状態でした。 原因を探ってみると、1つのチェックボックスをクリックした際に setCheckedIds によって親コンポーネントのstateが更新されていることが分かりました。 親コンポーネントが再レンダーされることで、handleCheck が useCallback でメモ化されていないため、毎回新しい関数参照が生成されます。 さらに、PropertyRow も React.memo でラップされていないため、propsが変更されたと判断され、結果として全500行が再レンダーされていました。

PropertyListTable.jsx — useCallback未使用

// 毎レンダーで新しい関数参照が生成される
const handleCheck = (id) => {
  setCheckedIds((prev) => {
    const next = { ...prev };
    if (next[id]) { delete next[id]; } else { next[id] = true; }
    return next;
  });
};

PropertyListTable.jsx — 全行にhandleCheckを渡している箇所

{paginatedData.map((property) => (
  <PropertyRow
    key={property.id}
    property={property}
    checked={!!checkedIds[property.id]}
    onCheck={handleCheck}  // ← 毎レンダーで新しい参照が渡される
  />
))}

PropertyRow.jsx — React.memo未使用

//React.memoで囲まれていないため、親の再レンダーで全行が再レンダーされる
function PropertyRow({ property, checked, onCheck }) {
  return (
    <tr>
      <td>
        <Checkbox
          checked={checked}
          onChange={() => onCheck(property.id)}
        />
      </td>
      ...
    </tr>
  );
}
export default PropertyRow;  // ← React.memo なし

Reactでは、親コンポーネントが再レンダーされると、子コンポーネントも基本的に再評価されます。 その際、propsの参照が変わっていると「変更があった」と判断され、再レンダーが実行されます。 今回のケースでは、handleCheck が毎回新しい関数として生成されていたため、onCheck の参照がレンダーごとに変わっていました。 さらに PropertyRow もメモ化されていなかったため、結果として全500行が再レンダーされていました。 そのため、以下の改善を行いました。

PropertyListTable.jsx — useCallback

// ❌ 改善前:毎レンダーで新しい関数参照が生成される
const handleCheck = (id) => {
  setCheckedIds((prev) => {
    const next = { ...prev };
    if (next[id]) { delete next[id]; } else { next[id] = true; }
    return next;
  });
};

// ✅ 改善後:useCallback で関数参照を安定化
const handleCheck = useCallback((id) => {
  setCheckedIds((prev) => {
    const next = { ...prev };
    if (next[id]) { delete next[id]; } else { next[id] = true; }
    return next;
  });
}, []);

PropertyRow.jsx — React.memo

// ❌ 改善前:親の再レンダーで全500行が再レンダーされる
function PropertyRow({ property, checked, onCheck }) {
  return (
    <tr>
      <td>
        <Checkbox
          checked={checked}
          onChange={() => onCheck(property.id)}
        />
      </td>
      ...
    </tr>
  );
}

export default PropertyRow;

// ✅ 改善後:React.memo で props が変わらない行の再レンダーをスキップ
import { memo } from "react";

const PropertyRow = memo(function PropertyRow({ property, checked, onCheck }) {
  return (
    <tr>
      <td>
        <Checkbox
          checked={checked}
          onChange={() => onCheck(property.id)}
        />
      </td>
      ...
    </tr>
  );
});

export default PropertyRow;
改善結果

useCallbackとReact.memoを適用した結果、処理時間が496msから88msへとさらに短縮されました。ほぼ最適な状態に到達したと言えます!

子コンポーネントのメモ化

ここでやっと最適化まで改善できましたが、React Scan上で Memoizable タグがついている箇所が気になりました。これらは現在 React.memo で囲まれておらず、PropertyRow2が再レンダーされるたびに連鎖的に再レンダーされています。処理時間バッジも緑ですが、各コンポーネントがすべてx1(1回)なのは単一チェックだからです。全選択時には500倍になるため、ここをメモ化しておくことで全選択時にさらに短縮できます。

// ❌ 改善前
function PriceCell({ price, previousPrice }) { ... }
export default PriceCell;

function PropertyName({ property }) { ... }
export default PropertyName;

// ✅ 改善後
function PriceCell({ price, previousPrice }) { ... }
export default React.memo(PriceCell);

function PropertyName({ property }) { ... }
export default React.memo(PropertyName);
改善結果

これにより、PropertyRow2 が再レンダーされた場合でも、PriceCell や PropertyName の props に変更がない限り再レンダーは発生しなくなりました✨

※Waveに Memoizable タグが付いていますが、これはAntdの内部コンポーネントのため、アプリ側のコードから直接メモ化することはできません。

最後に

Reactのパフォーマンス問題は、最初は感覚だけではなかなか原因にたどり着けませんでした。しかし、React Scanのようなツールで可視化し、数値をもとに一つずつ改善していけば、確実に結果につながることを実感できました。

ここまでお読みいただき、ありがとうございます。

弊社ではエンジニアを募集しております。

この記事を読んで少しでも興味を持ってくださった方は、ぜひカジュアル面談でお話ししましょう!

下記リンクよりご応募お待ちしております!

iimon採用サイト / Wantedly / Green