React, Vue.js
投稿日:

AIペアプロでReactのパフォーマンスボトルネックを特定・修正する

こんにちは、株式会社ファストコーディングのフルスタックエンジニア、独身貴族Fireです。

先日、バイクのエンジンオイルを交換したんです。交換前は「まあ走るし、別にいいか」と思っていたのですが、交換後にギアチェンジの滑らかさが明らかに変わった。体感できるレベルで違う。問題は、悪化が緩やかだと「これが普通」だと思い込んでしまうことなんですよね。

Reactアプリも同じです。「まあ動くし」で放置しているうちに、いつの間にか重くなっている。

AI駆動開発の文脈で、React DevTools Profilerの結果をAIに読ませてパフォーマンスのボトルネックを特定し、修正案を出させる実験をしてみました。useMemo、useCallback、React.memoの使い分けをAIがどう提案し、人間がどう判断したかの過程を共有します。

「遅い」のは感覚ではなく数値で捉える

パフォーマンス改善の第一歩は計測です。「なんか重い気がする」を「このコンポーネントのレンダリングに12ms かかっている」に変換する。React DevTools Profilerを使えば、各コンポーネントのレンダリング時間と再レンダリングの原因がわかります。

ただし、Profilerの出力を読み解くには経験が要ります。コンポーネントツリーが深いアプリだと、どこから手をつけるべきか判断が難しい。ここでAIの出番です。

私がやったのは、Profilerの結果をテキストに起こしてAIに渡し、「ボトルネックの優先順位をつけて」と依頼することでした。

実験に使ったサンプルアプリ

管理画面でよくある構成のアプリを用意しました。ユーザー一覧と詳細パネルがある画面です。

// App.tsx — パフォーマンス問題を含む初期版
'use client';

import { useState } from 'react';

type User = {
  id: number;
  name: string;
  email: string;
  department: string;
  role: string;
};

const USERS: User[] = Array.from({ length: 200 }, (_, i) => ({
  id: i + 1,
  name: `ユーザー ${i + 1}`,
  email: `user${i + 1}@example.com`,
  department: ['開発', '営業', 'デザイン', 'マーケ'][i % 4],
  role: ['管理者', '一般', 'ゲスト'][i % 3],
}));

export default function App() {
  const [selectedId, setSelectedId] = useState<number | null>(null);
  const [filter, setFilter] = useState('');
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

  const filteredUsers = USERS.filter(
    (u) =>
      u.name.includes(filter) ||
      u.department.includes(filter)
  );

  const selectedUser = USERS.find((u) => u.id === selectedId) ?? null;

  const stats = {
    total: filteredUsers.length,
    admins: filteredUsers.filter((u) => u.role === '管理者').length,
    departments: [...new Set(filteredUsers.map((u) => u.department))].length,
  };

  return (
    <div className={theme === 'dark' ? 'bg-gray-900 text-white' : 'bg-white text-gray-900'}>
      <Header
        theme={theme}
        onToggleTheme={() => setTheme((t) => (t === 'light' ? 'dark' : 'light'))}
      />
      <StatsBar stats={stats} />
      <div className="flex gap-4 p-4">
        <UserList
          users={filteredUsers}
          selectedId={selectedId}
          onSelect={setSelectedId}
          filter={filter}
          onFilterChange={setFilter}
        />
        <UserDetail user={selectedUser} />
      </div>
    </div>
  );
}

一見すると普通のコードに見えます。動作も問題なし。ただし、フィルタ入力のたびに全コンポーネントが再レンダリングされている状態でした。

AIにProfiler結果を渡す

React DevTools Profilerでフィルタ入力時の挙動を記録しました。1文字入力するたびに、以下のコンポーネントが再レンダリングされていました。

コンポーネントレンダリング時間原因
Header0.3ms親の再レンダリング
StatsBar0.5mspropsのstatsオブジェクトが毎回新規生成
UserList8.2msfilteredUsersが毎回新しい配列
UserRow(×200)各0.04ms親のUserListが再レンダリング
UserDetail0.8ms親の再レンダリング

合計で約12msです。60fpsを維持するには1フレーム16ms以内に収める必要があるため、フィルタ入力のたびに12ms使っているのはかなりギリギリです。データ量が増えれば確実にカクつきます。

この情報をAIに渡して、こう聞きました。

以下はReact DevTools Profilerの計測結果です。
フィルタ入力のたびに全コンポーネントが再レンダリングされています。

(上記のテーブルを貼り付け)

以下の制約で、優先度の高い順に修正案を出してください。
- React 19、Next.js 15 App Router
- useMemo / useCallback / React.memo を適切に使い分ける
- 過剰な最適化はしない(計測で効果が確認できるもののみ)

AIが出した修正案と、人間の判断

AIは4つの修正案を出してきました。それぞれに対する私の判断を記します。

修正案1:filteredUsersとstatsをuseMemoで計算(採用)

AIの提案は「フィルタ結果と統計情報をuseMemoでメモ化する」でした。

// Before:毎回再計算
const filteredUsers = USERS.filter(
  (u) => u.name.includes(filter) || u.department.includes(filter)
);

const stats = {
  total: filteredUsers.length,
  admins: filteredUsers.filter((u) => u.role === '管理者').length,
  departments: [...new Set(filteredUsers.map((u) => u.department))].length,
};
// After:useMemoでメモ化
const filteredUsers = useMemo(
  () =>
    USERS.filter(
      (u) => u.name.includes(filter) || u.department.includes(filter)
    ),
  [filter]
);

const stats = useMemo(
  () => ({
    total: filteredUsers.length,
    admins: filteredUsers.filter((u) => u.role === '管理者').length,
    departments: [...new Set(filteredUsers.map((u) => u.department))].length,
  }),
  [filteredUsers]
);

判断:採用。200件のフィルタリングはfilterが変わらない限り再計算不要です。statsもfilteredUsersに依存しているので、連鎖的にメモ化するのは理にかなっています。

修正案2:HeaderをReact.memoでメモ化(採用)

// Before
function Header({ theme, onToggleTheme }: HeaderProps) {
  return (
    <header className="flex justify-between items-center p-4 border-b">
      <h1 className="text-xl font-bold">ユーザー管理</h1>
      <button onClick={onToggleTheme}>
        {theme === 'light' ? '🌙' : '☀️'}
      </button>
    </header>
  );
}
// After
const Header = memo(function Header({ theme, onToggleTheme }: HeaderProps) {
  return (
    <header className="flex justify-between items-center p-4 border-b">
      <h1 className="text-xl font-bold">ユーザー管理</h1>
      <button onClick={onToggleTheme}>
        {theme === 'light' ? '🌙' : '☀️'}
      </button>
    </header>
  );
});

ただし、AIが指摘しなかった問題が1つあります。onToggleThemeがインライン関数なので、親が再レンダリングされるたびに新しい関数が渡され、React.memoが効きません。useCallbackとセットで使う必要があります。

// 親コンポーネントで
const handleToggleTheme = useCallback(
  () => setTheme((t) => (t === 'light' ? 'dark' : 'light')),
  []
);

判断:採用(ただしuseCallbackの追加が必要)。AIはReact.memoの提案は正しかったのですが、コールバック関数のメモ化という「セットで必要な対応」を見落としていました。ProfilerではHeaderのレンダリングコストは0.3msと小さいものの、React.memoとuseCallbackの典型的なパターンとして押さえておく価値があります。

修正案3:UserRowをReact.memoでメモ化(採用)

200件のUserRowが毎回再レンダリングされている問題への対策です。

// Before
function UserRow({ user, isSelected, onSelect }: UserRowProps) {
  return (
    <tr
      className={isSelected ? 'bg-blue-100' : 'hover:bg-gray-50'}
      onClick={() => onSelect(user.id)}
    >
      <td className="p-2">{user.name}</td>
      <td className="p-2">{user.email}</td>
      <td className="p-2">{user.department}</td>
      <td className="p-2">{user.role}</td>
    </tr>
  );
}
// After
const UserRow = memo(function UserRow({ user, isSelected, onSelect }: UserRowProps) {
  return (
    <tr
      className={isSelected ? 'bg-blue-100' : 'hover:bg-gray-50'}
      onClick={() => onSelect(user.id)}
    >
      <td className="p-2">{user.name}</td>
      <td className="p-2">{user.email}</td>
      <td className="p-2">{user.department}</td>
      <td className="p-2">{user.role}</td>
    </tr>
  );
});

ここでもonSelectのメモ化が必要です。

// 親コンポーネントで
const handleSelect = useCallback(
  (id: number) => setSelectedId(id),
  []
);

判断:採用。200件のリストで各行が0.04msでも、合計8msです。選択が変わったときに200件全部を再レンダリングする必要はなく、選択状態が変わった行だけ更新すれば十分です。React.memoによって、propsが変わらない行はスキップされます。

なお、UserRow内のonClick={() => onSelect(user.id)}はインライン関数ですが、UserRow自体がmemo化されているため、不要な再レンダリングがスキップされた結果、このインライン関数が新たに生成される頻度も抑制されます。

修正案4:UserDetailにuseMemoを適用(不採用)

AIはselectedUserの計算もuseMemoにすべきだと提案しました。

// AIの提案
const selectedUser = useMemo(
  () => USERS.find((u) => u.id === selectedId) ?? null,
  [selectedId]
);

判断:不採用Array.findで200件を検索するコストは無視できるレベルです。useMemoは依存配列の比較にもコストがかかるため、計算コストが低い処理をメモ化しても利点は少ない。React公式ドキュメントでも、useMemoは計算コストが高い処理(目安として1ms以上)に対して効果があるとされており、軽量な計算に対するメモ化は利点が少ないとされています。

ここがAIの弱点です。AIは「メモ化できるものはすべてメモ化する」傾向があります。しかし、メモ化にはメモリコストと比較コストがかかります。計測してボトルネックだと確認された箇所だけを最適化するのが正しいアプローチです。

修正後の全体コード

AIの提案に人間の判断を加えた最終版です。

// App.tsx — パフォーマンス改善版
'use client';

import { useState, useMemo, useCallback, memo } from 'react';

type User = {
  id: number;
  name: string;
  email: string;
  department: string;
  role: string;
};

type HeaderProps = {
  theme: 'light' | 'dark';
  onToggleTheme: () => void;
};

type StatsBarProps = {
  stats: { total: number; admins: number; departments: number };
};

type UserListProps = {
  users: User[];
  selectedId: number | null;
  onSelect: (id: number) => void;
  filter: string;
  onFilterChange: (value: string) => void;
};

type UserRowProps = {
  user: User;
  isSelected: boolean;
  onSelect: (id: number) => void;
};

type UserDetailProps = {
  user: User | null;
};

const USERS: User[] = Array.from({ length: 200 }, (_, i) => ({
  id: i + 1,
  name: `ユーザー ${i + 1}`,
  email: `user${i + 1}@example.com`,
  department: ['開発', '営業', 'デザイン', 'マーケ'][i % 4],
  role: ['管理者', '一般', 'ゲスト'][i % 3],
}));

const Header = memo(function Header({ theme, onToggleTheme }: HeaderProps) {
  return (
    <header className="flex justify-between items-center p-4 border-b">
      <h1 className="text-xl font-bold">ユーザー管理</h1>
      <button onClick={onToggleTheme} className="px-3 py-1 rounded border">
        {theme === 'light' ? '🌙 ダーク' : '☀️ ライト'}
      </button>
    </header>
  );
});

const StatsBar = memo(function StatsBar({ stats }: StatsBarProps) {
  return (
    <div className="flex gap-6 p-4 bg-gray-50 text-sm">
      <span>全 {stats.total} 件</span>
      <span>管理者 {stats.admins} 人</span>
      <span>{stats.departments} 部署</span>
    </div>
  );
});

const UserRow = memo(function UserRow({ user, isSelected, onSelect }: UserRowProps) {
  return (
    <tr
      className={`cursor-pointer ${isSelected ? 'bg-blue-100' : 'hover:bg-gray-50'}`}
      onClick={() => onSelect(user.id)}
    >
      <td className="p-2">{user.name}</td>
      <td className="p-2">{user.email}</td>
      <td className="p-2">{user.department}</td>
      <td className="p-2">{user.role}</td>
    </tr>
  );
});

function UserList({ users, selectedId, onSelect, filter, onFilterChange }: UserListProps) {
  return (
    <div className="flex-1">
      <input
        type="text"
        value={filter}
        onChange={(e) => onFilterChange(e.target.value)}
        placeholder="名前・部署で検索"
        className="w-full p-2 mb-4 border rounded"
      />
      <div className="overflow-x-auto">
        <table className="w-full border-collapse">
          <thead>
            <tr className="border-b bg-gray-50">
              <th className="p-2 text-left">名前</th>
              <th className="p-2 text-left">メール</th>
              <th className="p-2 text-left">部署</th>
              <th className="p-2 text-left">ロール</th>
            </tr>
          </thead>
          <tbody>
            {users.map((user) => (
              <UserRow
                key={user.id}
                user={user}
                isSelected={user.id === selectedId}
                onSelect={onSelect}
              />
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

function UserDetail({ user }: UserDetailProps) {
  if (!user) {
    return (
      <div className="w-80 p-4 border rounded bg-gray-50">
        <p className="text-gray-500">ユーザーを選択してください</p>
      </div>
    );
  }

  return (
    <div className="w-80 p-4 border rounded">
      <h2 className="text-lg font-bold mb-4">{user.name}</h2>
      <dl className="space-y-2">
        <div>
          <dt className="text-sm text-gray-500">メール</dt>
          <dd>{user.email}</dd>
        </div>
        <div>
          <dt className="text-sm text-gray-500">部署</dt>
          <dd>{user.department}</dd>
        </div>
        <div>
          <dt className="text-sm text-gray-500">ロール</dt>
          <dd>{user.role}</dd>
        </div>
      </dl>
    </div>
  );
}

export default function App() {
  const [selectedId, setSelectedId] = useState<number | null>(null);
  const [filter, setFilter] = useState('');
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

  const filteredUsers = useMemo(
    () =>
      USERS.filter(
        (u) => u.name.includes(filter) || u.department.includes(filter)
      ),
    [filter]
  );

  const stats = useMemo(
    () => ({
      total: filteredUsers.length,
      admins: filteredUsers.filter((u) => u.role === '管理者').length,
      departments: [...new Set(filteredUsers.map((u) => u.department))].length,
    }),
    [filteredUsers]
  );

  const selectedUser = USERS.find((u) => u.id === selectedId) ?? null;

  const handleToggleTheme = useCallback(
    () => setTheme((t) => (t === 'light' ? 'dark' : 'light')),
    []
  );

  const handleSelect = useCallback(
    (id: number) => setSelectedId(id),
    []
  );

  const handleFilterChange = useCallback(
    (value: string) => setFilter(value),
    []
  );

  return (
    <div
      className={`min-h-screen ${theme === 'dark' ? 'bg-gray-900 text-white' : 'bg-white text-gray-900'}`}
    >
      <Header theme={theme} onToggleTheme={handleToggleTheme} />
      <StatsBar stats={stats} />
      <div className="flex gap-4 p-4">
        <UserList
          users={filteredUsers}
          selectedId={selectedId}
          onSelect={handleSelect}
          filter={filter}
          onFilterChange={handleFilterChange}
        />
        <UserDetail user={selectedUser} />
      </div>
    </div>
  );
}

改善結果

修正後にProfilerで再計測しました。

コンポーネントBeforeAfter削減率
Header0.3ms(毎回)0.3ms(theme変更時のみ)再レンダリング回数が激減
StatsBar0.5ms(毎回)0.5ms(filter変更時のみ)再レンダリング回数が激減
UserList8.2ms2.1ms約74%削減
UserRow(×200)全件再レンダリング変更行のみ大半がスキップ
UserDetail0.8ms(毎回)0.8ms(選択変更時のみ)再レンダリング回数が激減

フィルタ入力時の合計レンダリング時間は約12msから約3msに改善しました。体感でもフィルタ入力の反応速度が明らかに向上しています。

useMemo / useCallback / React.memo の使い分け早見表

今回の実験で改めて整理した使い分けの基準です。

フック用途使うべき場面使わなくてよい場面
useMemo計算結果のメモ化配列のfilter/map/sort、オブジェクトの生成単純な値の参照、プリミティブ値の計算
useCallback関数のメモ化React.memoの子に渡すコールバックmemo化されていない子に渡す関数
React.memoコンポーネントの再レンダリングスキップリストの各行、propsが頻繁には変わらないコンポーネントpropsが毎回変わるコンポーネント

特に重要なのは、React.memoとuseCallbackはセットで使うということです。React.memoでコンポーネントをラップしても、親から渡されるコールバック関数が毎回新しいインスタンスだと、propsの比較で「変更あり」と判定されてしまいます。

AIが得意なこと、人間が判断すべきこと

今回の実験を通じて感じた、パフォーマンス最適化におけるAIの得意・不得意をまとめます。

AIが得意なこと:

  • Profilerの数値を整理して、ボトルネックの優先順位をつける
  • useMemo / useCallback / React.memoの具体的なコード変換
  • 「このコンポーネントが毎回再レンダリングされている」という事実の指摘

人間が判断すべきこと:

  • メモ化のコスト(メモリ・比較処理)と効果のトレードオフ
  • 「メモ化できる」と「メモ化すべき」の違い
  • React.memoとuseCallbackの組み合わせが必要かどうかの判断
  • ビジネス要件を踏まえた「どこまで最適化するか」の線引き

AIは「パターン」を見つけるのは得意ですが、「このパターンを適用するコストに見合うか」の判断は人間の仕事です。

まとめ

今回は、React DevTools Profilerの結果をAIに渡してパフォーマンスのボトルネックを特定・修正する実験を紹介しました。

AI駆動開発でパフォーマンス最適化に取り組む際のポイントは以下の3つです。

  1. まず計測する。React DevTools ProfilerでボトルネックをProfilerの数値として可視化し、AIに渡す材料を作る
  2. AIの提案を全採用しない。メモ化コストとのトレードオフを人間が判断し、計測で効果が確認できたものだけ採用する
  3. React.memoとuseCallbackはセットで使う。片方だけでは効果がないケースが多い。AIはこの「セット運用」を見落としがち

私たちの開発現場でも、このアプローチで管理画面のフィルタ操作のレスポンスを大幅に改善した実績があります。AIに計測結果を渡して提案を受け、人間がコストと効果を天秤にかけて判断する。この役割分担が、AI駆動開発におけるパフォーマンス最適化の現実的な進め方です。

なお、React 19ではReact Compiler(旧React Forget)が導入されています。React Compilerを有効にしている環境では、useMemo・useCallback・React.memoの手動メモ化の多くが自動化されるため、本記事で紹介したパターンがそのまま必要になるとは限りません。ただし、Compilerがカバーしないケースや、Compilerを導入していないプロジェクトでは、ここで紹介した手動メモ化の知識が引き続き役立ちます。

株式会社ファストコーディングでは、AI駆動開発を取り入れたReact/Next.jsの実装とパフォーマンス改善をサポートしています。「管理画面が重い」「リストの描画が遅い」といったお悩みがある方は、お問い合わせフォームからお気軽にご相談ください。