Claude Media
Claude CodeでReactの再レンダリングを絞る — Profilerの計測と描画回数テスト

Claude CodeでReactの再レンダリングを絞る — Profilerの計測と描画回数テスト

React DevToolsのProfilerで遅い部品を特定し、React Testing Libraryの描画回数テストで固定する流れを、Claude Codeへの渡し方とあわせて書きます。

Reactの再レンダリングを減らす作業は、直す前の計測と、直した後の固定がそろって初めて成立します。Claude Codeに「再レンダリングを減らして」とだけ頼むと、memoやuseCallbackが散らばるだけで、効いたかどうかが誰にも分からない差分になりがちです。

この記事は、Profilerの出力で遅い部品を絞り、React Testing Libraryで描画回数をテストにして、その失敗と成功をClaude Codeの完了条件にする手順です。途中で、手元の最小構成で試して分かった落とし穴を1つ取り上げます。<Profiler>は、描画回数のテストには使えません。

再レンダリングは減らす対象か、遅いものだけが対象か

再レンダリングとは、Reactがコンポーネント関数をもう一度呼び、前回と比べて画面の差分を計算することです。stateの更新が描画を予約し、親が再描画されれば子も再描画されるのが既定の動きです。

回数が多いこと自体は問題ではありません。memoの公式ページは、最適化が価値を持つのは「同じpropsで何度も再描画され、しかも描画処理が重い」場合だけだと書いています。体感できる遅さがなければ、memoは不要です。

そのため、依頼の順番は次のようになります。

手順

再レンダリング対策の進め方

  1. 1

    遅い操作を決める

    入力欄に打つ、タブを切り替えるなど、遅く感じる操作を1つに絞ります。

  2. 2

    Profilerで犯人を絞る

    その操作1回分のコミットを記録し、時間がかかった部品と、描画されるべきでなかった部品を見分けます。

  3. 3

    描画回数のテストを先に書く

    直す前に、期待する回数を書いたテストを走らせて失敗させます。

  4. 4

    直してテストを通す

    props、state、contextのどれが再描画を起こしているかに合わせて、最小の変更を入れます。

3番目を直す前に置く理由は、Claudeに「通るまで続ける」という終了条件を与えるためです。Claude Codeのベストプラクティスは、合否が返ってくる確認手段を渡すと、Claudeが変更、実行、出力の読み取り、修正を自分で回せるようになると説明しています。

Profilerの結果をClaude Codeにどう渡すか

DevToolsのProfilerタブは、記録を「コミット」単位で見せます。コミットはReactが画面を更新した1回分で、棒グラフの色と高さが所要時間を表します。フレームグラフでは、黄色が時間のかかった部品、青が軽かった部品、灰色がそのコミットで描画されなかった部品です。

操作1回分のコミットを選ぶと、描画されるべきでない部品が黄色や青で並んでいる様子が見えます。ここが絞り込みの入口です。

Claude Codeに渡す方法は、公式が挙げる3つの形がそのまま使えます。

渡すもの方法向く場面
画面方法ランク付きチャートのスクリーンショットをプロンプトに貼る向く場面どの部品が重いかを見せたい
ファイル方法計測結果を書き出したテキストを@で指定する向く場面数値を正確に読ませたい
コマンド出力方法cat render.log | claude -p "..."のようにパイプする向く場面繰り返し流したい

スクリーンショットは「どの部品か」を伝えるのに向き、数値はonRenderのログのほうが正確に渡せます。<Profiler>コンポーネントのonRenderは、phase、actualDuration、baseDurationなどを受け取るコールバックです。actualDurationはメモ化がどれだけ効いているかの目安で、初回の描画のあとは大きく減るのが理想だと公式に書かれています。baseDurationは最適化なしで全体を再描画した場合の見積もりなので、2つを比べるとメモ化の効き具合が分かります。

ログを取る薄い入口は、遅い画面だけを<Profiler>で包んで書き出す形です。

import { Profiler } from 'react';
 
function logRender(
  id: string,
  phase: string,
  actualDuration: number,
  baseDuration: number,
) {
  console.log(
    JSON.stringify({ id, phase, actualDuration, baseDuration }),
  );
}
 
<Profiler id="catalog" onRender={logRender}>
  <Catalog items={items} />
</Profiler>

ブラウザーのPerformanceパネルで見たい場合は、React Performance tracksという手段もあります。開発ビルドでは既定で有効で、Performanceパネルの時系列に、更新のきっかけ、描画、コミットの各段階が並びます。ネットワークやJavaScriptの実行と同じ時間軸で見られるため、再描画が入力イベントの直後に起きているかを確かめたいときに向きます。コミット単位で部品ごとの所要時間を見比べるなら、Profilerタブのほうが手早く読めます。

Claudeへの依頼文は、画面、操作、期待を1つずつ書きます。

Catalogの検索欄に1文字入力すると、50行のRowが全部再描画される気がする。
@render.log は入力1回分の onRender の出力。
犯人のpropsを特定して、先に失敗する描画回数テストを書いてから直して。
memo類の追加は、ログで遅いと分かった箇所だけにして。

「何が失敗として見えるか」を依頼の中に入れておくと、Claudeが勘でuseCallbackを足す動きを抑えられます。

Profilerでは描画回数のテストにならない

ここで、最初に思いつく近道に落とし穴があります。テストの中で<Profiler>を子の前に挟み、onRenderが呼ばれた回数を数える方法です。

手元の最小構成(React 19.3.1、React Testing Library 16.3.3、Vitest 2.1.9、jsdom 25)で、50行のリストを親が持つ検索欄の入力で再描画させ、<Profiler>で包んでonRenderのphaseを記録しました。リストをmemoで包んだ場合も、素の関数のままの場合も、入力1回あたりupdateが1回呼ばれました。propsの配列を毎回作り直した場合や、StrictModeで描画した場合も同じです。

リストの形検索欄に1文字入力した後のonRender
素の関数コンポーネント検索欄に1文字入力した後のonRenderupdateが1回
memoで包んだ検索欄に1文字入力した後のonRenderupdateが1回
memoで包み、配列を毎回新規作成検索欄に1文字入力した後のonRenderupdateが1回
memoで包み、StrictModeで描画検索欄に1文字入力した後のonRenderupdateが1回

onRenderは、Profilerで包んだ範囲が更新されるたびに呼ばれます。外側の親が再描画されれば、中の子がメモ化で描画を省いても、Profiler自体の更新としては数えられるということです。公式のProfilerの説明は、onRenderを「更新のたびに呼ばれ、描画の内容と所要時間を受け取るもの」と位置づけています。

落とし穴

Profilerの呼び出し回数は子の描画回数ではない

<Profiler>は「どれだけ時間がかかったか」を測る道具です。「この部品の関数が何回呼ばれたか」を数える道具ではありません。回数の固定には、部品の関数そのものを数える仕組みが要ります。

actualDurationで比べる手もありますが、ミリ秒の閾値は環境で揺れ、CIで不安定になります。回数なら揺れません。

描画回数を固定するテストの書き方

部品の関数が何回呼ばれたかを数えるには、対象の部品を、同じmemo構造を保ったままスパイ付きのものに差し替えます。例では、RowをRowViewとmemoの組に分けておきます。

// Row.tsx
import { memo } from 'react';
 
export type Item = { id: number; name: string };
 
export function RowView({ item }: { item: Item }) {
  return <li>{item.name}</li>;
}
export const Row = memo(RowView);
// Catalog.tsx
import { useState } from 'react';
import { Row, Item } from './Row';
 
export function Catalog({ items }: { items: Item[] }) {
  const [q, setQ] = useState('');
  return (
    <div>
      <input
        aria-label="search"
        value={q}
        onChange={(e) => setQ(e.target.value)}
      />
      <ul>
        {items.map((i) => <Row key={i.id} item={i} />)}
      </ul>
    </div>
  );
}

テストでは、vi.mockでRowを「memoで包んだスパイ入りの部品」に置き換え、入力の前後で呼び出し回数を比べます。

// Catalog.test.tsx
import { memo } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { vi, it, expect, beforeEach } from 'vitest';
import { Catalog } from './Catalog';
import type { Item } from './Row';
 
const rowRender = vi.hoisted(() => vi.fn());
vi.mock('./Row', async (importOriginal) => {
  const mod = await importOriginal<typeof import('./Row')>();
  const Counted = memo((props: { item: Item }) => {
    rowRender(props.item.id);
    return mod.RowView(props);
  });
  return { ...mod, Row: Counted };
});
 
const items: Item[] = Array.from(
  { length: 50 },
  (_, i) => ({ id: i, name: `item ${i}` }),
);
beforeEach(() => rowRender.mockClear());
 
it('検索語を入力しても Row は再描画されない', () => {
  render(<Catalog items={items} />);
  expect(rowRender).toHaveBeenCalledTimes(50);
  rowRender.mockClear();
  fireEvent.change(screen.getByLabelText('search'), {
    target: { value: 'a' },
  });
  expect(rowRender).toHaveBeenCalledTimes(0);
});

renderとscreen、fireEventは、React Testing Libraryの標準の入口です。インストールは、本体に@testing-library/domを添える形です。

npm install --save-dev @testing-library/react @testing-library/dom
npx vitest run Catalog.test.tsx

同じ構成で通る側と落ちる側を確かめました。Rowがmemoのときは、初回の描画で50回、入力後は0回で通ります。Rowからmemoを外すと、同じテストが次のメッセージで落ちます。

expected "spy" to be called +0 times, but got 50 times

この失敗は、Claudeへの指示として使えます。「このテストが通るまで直して」と書けば、犯人探しの結果がそのまま終了条件になります。ただし、モックの書き方まで毎回Claudeに考えさせるのは無駄です。上の型は、テストファイルの雛形としてリポジトリに置いておきます。

失敗の原因別に、直し方を選ぶ

テストが落ちたとき、原因は大きく3つに分かれます。memoの公式ページが挙げる挙動と対応させると、次のとおりです。

原因見分け方直し方
propsが毎回別の参照になる見分け方オブジェクト、配列、関数を描画中に作っている直し方作成を親の外に出すか、useMemo/useCallbackで保つ
自身のstateやcontextが変わる見分け方memoをかけても再描画される直し方contextの読み取りを外側で行い、memoした子にpropsで渡す
親がラッパーとして子を抱える見分け方親のstate更新で子も描画される直し方子をJSXのchildrenとして受け取る形にする

memoはpropsをObject.isで比べるため、Object.is({}, {})はfalseになります。描画中に作ったオブジェクトや関数を渡すと、memoは効きません。このためuseMemoとuseCallbackがmemoとセットで使われます。また、memoで包んでいても、自分のstateや使っているcontextが変わると再描画されます。

ここでの直し方は、描画回数テストが落ちた理由をClaudeに言わせてから変えさせるのが安全です。

テストの失敗を、propsの参照が変わっているのか、
stateかcontextの更新なのか、どちらか特定してから直して。
特定できる根拠として、変わっているpropの名前を挙げて。

React Compilerを有効にしているプロジェクトでは、前提が変わります。公式は、コンパイラがReact.memo相当の最適化を自動で行い、手動のmemoを外してよい場合があると説明しています。コンパイラ有効のコードに、Claudeが機械的にmemoを足すと、重複した最適化と読みにくさだけが増えます。依頼文に「React Compilerが有効」と一言入れておきます。

CLAUDE.mdに入れる4行

この手順を毎回の依頼に書かないために、CLAUDE.mdへ短く置けます。

# Commands
- 描画回数テスト: npx vitest run -t "再描画"
 
# Conventions
- 再レンダリングの修正は、描画回数テストを先に書いて失敗を確認してから直す
- memo / useMemo / useCallback は、Profilerで遅いと分かった部品だけに足す
- 描画回数は StrictMode なしの render で数える

公式のガイドは、CLAUDE.mdには「コードを読めば分かること」を書かず、読んでも見つからない情報だけを書くよう勧めています。上の4行は、テストの実行コマンド1行と、チームの手順の優先順位3行という、リポジトリから読み取りにくい内容に絞っています。

数がずれる原因は、たいていStrictModeかモックの片付け

描画回数のテストが不安定なときは、次の2点を先に疑います。

StrictModeは、開発時に純粋でない描画を見つけるため、一部の関数を2回呼びます。同じ最小構成で、reactStrictMode: trueを付けて50行を初回描画したところ、スパイが記録した呼び出しは100回でした。期待値を50にしたテストがStrictModeの有無で食い違うのは、このためです。React Testing LibraryのrenderはreactStrictModeオプションを持っており、既定で付くわけではありません。回数を数えるテストでは、付けない設定で固定します。

もう1つは、初回描画の回数をクリアし忘れることです。入力前の描画をmockClearで消さないと、「入力後に0回」の期待が初回の50回を数えて落ちます。上のテストでは、期待値の確認とmockClearを入力の直前にそろえています。

計測と固定を、1回の依頼で通す

冒頭の4段の手順を1回の依頼で通すときは、Claude CodeでINPを改善する手順と同じ考え方が使えます。操作を決め、計測し、同じ条件で取り直す流れです。

再レンダリングの場合に足すのは、実行したコマンドとその出力を見せてもらう指定です。「テストが通りました」だけの報告では、読む側が確かめられません。出力があれば再実行の手間も省けます。テスト実行を自動で呼びたい場合は、VitestのコマンドをCLAUDE.mdに書く方法に沿って、テストの起動を固定しておきます。

React 19への移行と同時に直す場合は、React 19のアップグレード手順のあとに行うと、移行起因の描画回数の変化と切り分けられます。画面の見た目の揺れが気になる場合は、再レンダリングではなくCLSの原因を探す手順が対象です。

まとめ

再レンダリングの対策で効くのは、memoを足す作業よりも、「何が何回描画されるべきか」をテストに書いておく作業です。Profilerは遅さの特定に、部品の関数を数えるテストは回数の固定に、と役割を分けると、Claude Codeに渡す依頼も短くなります。<Profiler>の呼び出し回数を回数の根拠にしないこと。それだけで、メモ化を足したのに効いていない差分を避けられます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/best-practices
  2. react.dev/reference/react/Profiler
  3. react.dev/reference/react/memo
  4. react.dev/reference/react/StrictMode
  5. testing-library.com/docs/react-testing-library/api
この記事を共有:XはてブLinkedIn