Reactアプリを破滅させる5つのパフォーマンスの落とし穴
ほとんどのReactアプリは、必要以上に低速です。本番環境のコードベースでよく見かける5つのパフォーマンスの落とし穴と、それぞれの修正方法を紹介します。

私は長年にわたり、何百ものReactコードベースをレビューしてきました。クライアントプロジェクト、オープンソースリポジトリ、社内ツール、スタートアップのMVPなどです。同じパフォーマンスの問題が繰り返し現れます。それは珍しいエッジケースではなく、アプリがもっさりしてユーザーが離れていくまで積み重なる、基本的で予防可能なミスです。
ここでは、私が最もよく目にする5つの問題と、今日から適用できる具体的な修正方法を紹介します。
1. メモ化の欠如による不要な再レンダリング
これはReactアプリで最もよく見られるパフォーマンスの問題です。親コンポーネントが再レンダリングされると、その子コンポーネントもすべて再レンダリングされます。たとえpropsが変わっていなくてもです。これが、リストに何十ものアイテムがある深いコンポーネントツリー全体で発生すると、キー入力のたびにアプリがカクつくようになります。
問題点:
function Dashboard({ user }) {
const [searchQuery, setSearchQuery] = useState("");
const stats = calculateStats(user.transactions);
return (
<div>
<SearchBar value={searchQuery} onChange={setSearchQuery} />
<StatsPanel stats={stats} />
<TransactionList transactions={user.transactions} />
</div>
);
}
searchQueryが変更されるたびに、user.transactionsが変わっていなくてもcalculateStatsが再度実行されます。StatsPanelとTransactionListも理由なく再レンダリングされます。
修正方法:
function Dashboard({ user }) {
const [searchQuery, setSearchQuery] = useState("");
const stats = useMemo(
() => calculateStats(user.transactions),
[user.transactions]
);
const handleSearchChange = useCallback(
(value: string) => setSearchQuery(value),
[]
);
return (
<div>
<SearchBar value={searchQuery} onChange={handleSearchChange} />
<MemoizedStatsPanel stats={stats} />
<MemoizedTransactionList transactions={user.transactions} />
</div>
);
}
const MemoizedStatsPanel = memo(StatsPanel);
const MemoizedTransactionList = memo(TransactionList);
ここでは3つのことが行われました。
useMemoは、calculateStatsがすべてのレンダリングで実行されるのを防ぎます。user.transactionsが実際に変更されたときにのみ再計算されます。useCallbackは、変更ハンドラの参照を安定させ、SearchBarがすべてのレンダリングで「新しい」関数を見ないようにします。memoは子コンポーネントをラップし、propsがシャロー比較で等しい場合に再レンダリングをスキップさせます。
注意点として、すべてのコンポーネントを盲目的にmemoでラップしないでください。まずReact DevTools Profilerを使ってプロファイリングしてください。メモ化にはコストがかかります — シャロー比較自体がそうです。高価な計算、大きなリスト、propsが変わっていないのに頻繁に再レンダリングされるコンポーネントなど、重要な箇所に適用してください。
2. コード分割の欠如によるバンドルの肥大化
私は、すべてを単一のJavaScriptバンドルで提供しているReactアプリをよく見かけます。管理ダッシュボード、設定ページ、めったにアクセスされないヘルプセンターなど、すべてが最初のページ訪問時に事前に読み込まれます。ユーザーはランディングページを見たかっただけなのに、ブラウザはまだ必要ない2MBのJavaScriptをダウンロードしてしまったのです。
問題点:
import AdminDashboard from "./pages/AdminDashboard";
import Settings from "./pages/Settings";
import HelpCenter from "./pages/HelpCenter";
import Landing from "./pages/Landing";
function App() {
return (
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/help" element={<HelpCenter />} />
</Routes>
);
}
静的インポートは、バンドラーがすべてを1つのチャンクに含めることを意味します。ユーザーがどのルートにアクセスしても、すべてをダウンロードすることになります。
修正方法:
import { lazy, Suspense } from "react";
const AdminDashboard = lazy(() => import("./pages/AdminDashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const HelpCenter = lazy(() => import("./pages/HelpCenter"));
import Landing from "./pages/Landing"; // Keep critical path static
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/help" element={<HelpCenter />} />
</Routes>
</Suspense>
);
}
動的なimport()とReact.lazyを組み合わせることで、バンドラーはこれらのページを別々のチャンクに分割するよう指示されます。ユーザーがそのルートに移動したときにオンデマンドで読み込まれます。これにより、初期バンドルは小さく保たれ、Time to Interactiveが大幅に短縮されます。
Next.jsを使用している場合、App Routerを使えばルートベースのコード分割が無料で提供されます。しかし、重いサードパーティライブラリについては、コンポーネントレベルの分割を意図的に行う必要があります。
import dynamic from "next/dynamic";
const RichTextEditor = dynamic(() => import("@/components/RichTextEditor"), {
loading: () => <EditorSkeleton />,
ssr: false,
});
これは、リッチテキストエディタ、チャートライブラリ、PDFレンダラーなど、バンドルに簡単に200〜500KBを追加してしまうようなライブラリにとって非常に重要です。
3. コンポーネントツリーのあまりにも高い位置に配置された状態
Reactのレンダリングモデルはトップダウンです。コンポーネントの状態が変更されると、そのコンポーネントと、そのサブツリー全体が再レンダリングされます。状態を「便宜上」ツリーの最上位に持ち上げると、Reactに状態が変更されるたびにその下にあるすべてを再レンダリングするよう指示していることになります。
問題点:
function App() {
const [selectedFilter, setSelectedFilter] = useState("all");
const [isModalOpen, setIsModalOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
return (
<Layout>
<Header />
<Sidebar filter={selectedFilter} onFilterChange={setSelectedFilter} />
<MainContent>
<SearchBar query={searchQuery} onChange={setSearchQuery} />
<ProductList filter={selectedFilter} />
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
</MainContent>
<Footer />
</Layout>
);
}
検索バーに入力すると、Appで状態の更新がトリガーされます。これにより、Header、Sidebar、Footer、ProductList、Modalが再レンダリングされますが、これらのどれも検索クエリには関心がありません。
修正方法:
状態が実際に使用される場所まで押し下げます。
function App() {
return (
<Layout>
<Header />
<SidebarWithFilter />
<MainContent>
<SearchSection />
<ProductList />
</MainContent>
<Footer />
</Layout>
);
}
function SearchSection() {
const [searchQuery, setSearchQuery] = useState("");
return (
<div>
<SearchBar query={searchQuery} onChange={setSearchQuery} />
<SearchResults query={searchQuery} />
</div>
);
}
これで、検索バーに入力してもSearchSectionとその子コンポーネントのみが再レンダリングされます。他のすべては変更されません。
原則はシンプルです。状態は、それを読み取るコンポーネントにできるだけ近い場所に置くべきです。 2つの兄弟コンポーネントが同じ状態を必要とする場合は、それらの最も近い共通の親に持ち上げますが、それ以上高くはしません。真にグローバルな状態(認証、テーマ、ロケールなど)については、ZustandやJotaiのように選択的なサブスクリプションをサポートする状態管理ライブラリを使用し、特定の状態スライスを消費するコンポーネントのみが、そのスライスが変更されたときに再レンダリングされるようにします。
4. Next.jsにおける最適化されていない画像
画像は通常、ウェブページ上で最も重いアセットです。Next.jsアプリで、開発者が巨大で非圧縮のPNGを通常の<img>タグで使用しているのを見たことがあります。その結果、8MBもの画像を読み込み、遅延読み込みはゼロ、レスポンシブなサイズ調整もなく、Largest Contentful Paintの時間がLighthouseを泣かせるようなページになっていました。
問題点:
function ProjectCard({ project }) {
return (
<div className="card">
<img src={`/images/${project.cover}`} alt={project.title} />
<h3>{project.title}</h3>
<p>{project.description}</p>
</div>
);
}
これは、ユーザーのビューポートやデバイスに関係なく、元のサイズのフル解像度画像を配信します。フォーマット変換も、遅延読み込みも、レスポンシブなsrcsetもありません。
修正方法:
import Image from "next/image";
function ProjectCard({ project }) {
return (
<div className="card">
<Image
src={`/images/${project.cover}`}
alt={project.title}
width={600}
height={400}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
placeholder="blur"
blurDataURL={project.blurHash}
/>
<h3>{project.title}</h3>
<p>{project.description}</p>
</div>
);
}
Next.jsのImageコンポーネントは、いくつかのことを自動的に提供します。
- ブラウザのサポートに基づいてWebPまたはAVIFへの自動フォーマット変換
sizes属性によるレスポンシブなサイズ調整 — ブラウザは必要なサイズのみをダウンロードします- デフォルトでの遅延読み込み — スクロールして近くに来るまで、ファーストビュー外の画像はフェッチされません
- ぼかしプレースホルダー — フル画像が読み込まれる間、軽量なプレビューを表示し、レイアウトシフトを排除します
ファーストビュー内の画像(ヒーローバナー、主要コンテンツ)については、遅延読み込みを無効にし、プリロードするためにpriorityを追加します。
<Image src={heroImage} alt="Hero" priority sizes="100vw" />
この変更一つだけで、ページウェイトを60〜80%削減し、Core Web Vitalsスコアを劇的に改善することができます。
5. 重い計算によるメインスレッドのブロック
ブラウザのメインスレッドは、レンダリング、ユーザー入力、アニメーション、JavaScriptの実行をすべて単一のスレッドで処理します。重い計算を同期的に実行すると、他のすべてがフリーズします。ユーザーがボタンをクリックしても500ms間何も起こりません。アニメーションはカクつき、スクロールは反応しなくなります。
問題点:
function AnalyticsDashboard({ rawData }) {
const [processedData, setProcessedData] = useState(null);
useEffect(() => {
// This blocks the main thread for 200-800ms
const result = processLargeDataset(rawData); // Sorting, filtering, aggregating 50k+ rows
setProcessedData(result);
}, [rawData]);
if (!processedData) return <Spinner />;
return <Charts data={processedData} />;
}
processLargeDatasetはメインスレッドで同期的に実行されます。もし400msかかると、UI全体が400ms間フリーズします。ユーザーはスクロール、入力、または何も操作できません。
修正方法 — Web Workerに移動する:
// workers/dataProcessor.ts
self.onmessage = (event: MessageEvent) => {
const rawData = event.data;
const result = processLargeDataset(rawData);
self.postMessage(result);
};
function AnalyticsDashboard({ rawData }) {
const [processedData, setProcessedData] = useState(null);
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
workerRef.current = new Worker(
new URL("../workers/dataProcessor.ts", import.meta.url)
);
workerRef.current.onmessage = (event: MessageEvent) => {
setProcessedData(event.data);
};
workerRef.current.postMessage(rawData);
return () => {
workerRef.current?.terminate();
};
}, [rawData]);
if (!processedData) return <Spinner />;
return <Charts data={processedData} />;
}
重い計算は別のスレッドで実行されるようになりました。メインスレッドはユーザーインタラクション、アニメーション、レンダリングを処理するために解放されたままになります。UIは常に応答性を保ちます。
計算が50〜150msかかるような、それほど極端ではないケースでは、完全なWorkerを立ち上げる代わりにrequestIdleCallbackやscheduler.yield()を使って作業を小さなチャンクに分割することができます。しかし、大規模なデータセットを処理したり、レポートを生成したり、複雑なアルゴリズムを実行したりするようなものについては、Web Workerはオプションではなく、必須です。
複合的な影響
これらのミスはどれも単独でアプリをクラッシュさせることはありません。それが危険な理由です。それぞれが100〜300msの遅延を追加し、ユーザーはその合計を体験します。不要な再レンダリングをするコンポーネント、肥大化したバンドル、最適化されていない画像、メインスレッドでの計算が組み合わさると、アプリがインタラクティブになるまでに1秒ではなく4秒かかることになります。
最適化する前にプロファイリングしましょう。 React DevTools Profilerを使って再レンダリングのホットスポットを見つけましょう。LighthouseとWeb Vitalsを使って実際のユーザーへの影響を測定しましょう。ブラウザのネットワークタブを使って肥大化したバンドルや画像を特定しましょう。パフォーマンスタブを使ってメインスレッドをブロックしている長いタスクを特定しましょう。
これらは、レストランプラットフォームから医療SaaSシステムまで、私が構築するすべてのプロジェクトで適用しているパターンです。パフォーマンスは後から追加する機能ではありません。最初のコミットから維持すべき品質なのです。