Reactの「描画は速いがアプリは重い」という現象は、99%が再レンダリングの設計ミスとネットワーク・バンドル設計の甘さに起因します。本記事では React 19 / React Compiler 時代の最適化戦略を、計測 → 再レンダリング削減 → コード分割 → 仮想化 → Web Vitals 改善 という順で、コピペで動くコードを軸に総整理します。
対象読者は React.memo / useMemo / useCallback を使ってはいるが「効いているのか分からない」「貼ったら逆に遅くなった」と感じている20〜40代の現役Webエンジニアです。「測ってから直す」を一貫した方針として進めます。
- パフォーマンス最適化の全体像と優先順位
- 計測ファースト:React DevTools Profiler
- React.memoの正しい使い方と落とし穴
- useMemo / useCallback を「正しく」使う
- React Compiler:メモ化の自動化(2026年現在)
- Context最適化:Provider分割とselector
- state の「持ち位置」を最適化する
- useEffect を減らす(派生stateの削除)
- データフェッチのキャッシュ:TanStack Query / SWR
- コード分割:React.lazy と Suspense
- 仮想スクロール:1万件を60fpsで描く
- スタイリングのコスト:CSS-in-JS 削減
- 画像とフォントの最適化
- Web Vitals 測定とINP/LCP/CLS対策
- バンドル解析と Tree Shaking
- キャッシュ層:Service Worker と HTTP cache
- Suspenseと並行レンダリングの実戦
- SSR / RSC によるネットワーク削減
- 計測〜改善のワークフロー実例
- 独学で詰まったら:質問できる環境を用意する
- よくある質問(FAQ)
- まとめ:測って・削って・分けて・遅らせる
パフォーマンス最適化の全体像と優先順位
最適化に飛びつく前に、効果が大きい順で投資先を決めるべきです。実プロダクトで効くのは「測定 → ネットワーク/バンドル削減 → 再レンダリング削減 → メモ化 → 仮想化」の順で、これは Lighthouse スコアとも素直に一致します。
| レイヤ | 主な指標 | 代表施策 | 効果規模 |
|---|---|---|---|
| 計測 | Profiler / Web Vitals | DevTools / web-vitals | 前提 |
| ネットワーク | LCP / TTFB | SSR・CDN・画像最適化 | 非常に大 |
| バンドル | JSサイズ | code splitting / tree shaking | 大 |
| レンダリング | INP / commit時間 | state分割 / memo / Compiler | 中〜大 |
| 大量描画 | commit時間 | 仮想スクロール | 大(リスト系のみ) |
| レイアウト | CLS | サイズ予約・font-display | 中 |
「memoを貼れば速くなる」という幻想を捨て、まず本当に再レンダリングが原因かを Profiler で確認するところから始めます。useMemoとuseCallbackのミクロな違いは本記事では深追いせず、本稿はマクロな最適化戦略に集中します。
計測ファースト:React DevTools Profiler
React DevTools の Profiler は、各コミットごとに「どのコンポーネントが、なぜ再レンダリングしたか」を可視化します。本番計測には Profiler API を使い、開発時には拡張機能の Profilerタブを使い分けます。
Profilerタブで「なぜrenderした」を見る
DevTools の歯車アイコンから 「Record why each component rendered while profiling」 をONにしておきます。これで各コミットに「props changed」「hooks changed」「parent rendered」などの理由が出るようになります。
// 開発時のみ Profiler を仕込み、commit時間を計測する
import { Profiler, type ProfilerOnRenderCallback } from "react";
const onRender: ProfilerOnRenderCallback = (
id, // Profiler の "id" prop
phase, // "mount" | "update" | "nested-update"
actualDuration, // 今回のコミットでこのツリーが消費した時間(ms)
baseDuration, // memo化なしで掛かるであろう時間(ms)
startTime, // コミット開始時刻
commitTime // コミット完了時刻
) => {
if (actualDuration > 16) {
// 60fps境界(16.6ms)を超えたコミットだけログ
console.warn(`[slow-commit] ${id} ${phase} ${actualDuration.toFixed(1)}ms`);
}
};
export const ProfiledRoot = ({ children }: { children: React.ReactNode }) => (
<Profiler id="root" onRender={onRender}>{children}</Profiler>
);
本番でも軽く計測する
// 本番でも 1% サンプリングで Profiler を回す
const SAMPLE_RATE = 0.01;
const enabled = Math.random() < SAMPLE_RATE;
export const SampledProfiler = ({ id, children }: {
id: string;
children: React.ReactNode;
}) => {
if (!enabled) return <>{children}</>;
return (
<Profiler id={id} onRender={(_, phase, actual) => {
navigator.sendBeacon("/api/perf", JSON.stringify({ id, phase, actual }));
}}>
{children}
</Profiler>
);
};
why-did-you-render で過剰renderを炙り出す
@welldone-software/why-did-you-render は「propsが浅い等価なのに再renderされた」コンポーネントを開発時にconsoleで通知してくれます。React 19 でも動作するので、最初の一週間だけONにしてホットスポットを潰すのが定石です。
// src/wdyr.ts (エントリより前にimport)
import React from "react";
if (process.env.NODE_ENV === "development") {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const whyDidYouRender = require("@welldone-software/why-did-you-render");
whyDidYouRender(React, {
trackAllPureComponents: true, // memo化済を全部追跡
trackHooks: true,
logOnDifferentValues: true,
collapseGroups: true,
});
}
// src/main.tsx
import "./wdyr"; // ← 必ず React のimportより前
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(<App />);
// 個別のコンポーネントだけ追跡したいとき
const Heavy = ({ data }: { data: Item[] }) => { /* ... */ };
// @ts-expect-error wdyr augments
Heavy.whyDidYouRender = true;
export default Heavy;
React.memoの正しい使い方と落とし穴
React.memo は「props が浅い比較で等しければ再renderをスキップ」する仕組みです。ただしprops にオブジェクト・配列・関数を渡している場合、毎回新しい参照になるため一切効きません。これが一番多い失敗です。
Before:memoが効いていない典型
// ❌ memoしても親renderの度に { id, label } が新規生成→必ず再render
const Row = React.memo(({ item }: { item: Item }) => {
console.log("render", item.id);
return <li>{item.label}</li>;
});
const List = ({ items }: { items: Item[] }) => {
return (
<ul>
{items.map(i => (
<Row key={i.id} item={{ id: i.id, label: i.label }} />
))}
</ul>
);
};
After:参照を安定化する
// ✅ items自体の要素を渡せば参照が安定し、memoが効く
const Row = React.memo(({ item }: { item: Item }) => (
<li>{item.label}</li>
));
const List = ({ items }: { items: Item[] }) => (
<ul>{items.map(i => <Row key={i.id} item={i} />)}</ul>
);
比較関数(areEqual)を渡すパターン
// 特定のフィールドだけで等価判定したい時
type Props = { item: Item; onClick: (id: string) => void };
const Row = React.memo(
({ item, onClick }: Props) => (
<li onClick={() => onClick(item.id)}>{item.label}</li>
),
(prev, next) =>
prev.item.id === next.item.id &&
prev.item.label === next.item.label &&
prev.item.updatedAt === next.item.updatedAt
// onClick の参照差は無視する(idだけが本質)
);
比較関数は関数の引数等価まで無視できる強力なツールですが、書き間違えると「更新されないバグ」を生みます。デフォルトの浅い比較で済むよう、props設計を見直す方が先です。
memoを貼るべきでないケース
- レンダリングコストが小さい(テキストのみのコンポーネントなど)
- 毎回propsが変わる(無駄な比較コストだけが残る)
- children が ReactNode で常に新規参照(
<Card><Inner /></Card>) - 親と一緒に必ず更新される(memoしても無意味)
- テスト目的に近い極小コンポーネント
useMemo / useCallback を「正しく」使う
useMemo / useCallback は「子コンポーネントに渡す参照を安定化する」または「明確に重い計算をスキップする」場合だけ価値があります。それ以外は害(メモリ・依存配列バグ)の方が大きいです。
useMemo:重い計算をスキップ
// 1万件の絞り込みを keyword 変化時だけ再計算
const filtered = useMemo(
() => items.filter(i => i.label.toLowerCase().includes(keyword.toLowerCase())),
[items, keyword]
);
// ❌ Date.now() のような毎回変わる値を依存にしない
// ❌ 数値の足し算など軽すぎる計算には不要
useCallback:memo化された子に関数を渡すとき
// memo化された Row に渡すコールバックを安定化
const handleClick = useCallback(
(id: string) => setSelectedId(id),
[] // setStateは安定参照なので空でOK
);
return items.map(i => <Row key={i.id} item={i} onClick={handleClick} />);
useEvent パターン(最新ref経由でラッチ)
// React 公式の useEvent は未リリースだが自前で代替可能
import { useRef, useCallback, useLayoutEffect } from "react";
export function useEvent<T extends (...a: any[]) => any>(fn: T): T {
const ref = useRef(fn);
useLayoutEffect(() => { ref.current = fn; });
return useCallback(((...args) => ref.current(...args)) as T, []);
}
// 使い方:常に最新のstateを参照しつつ、参照は不変
const handleSearch = useEvent((q: string) => {
trackEvent("search", { q, page: currentPage });
});
React Compiler:メモ化の自動化(2026年現在)
2024年に正式RC、2026年現在ではStableに到達した React Compiler(旧React Forget)は、ビルド時にコンポーネントを解析して必要箇所に自動で memo / useMemo / useCallback 相当のコードを差し込みます。手で useCallback を貼る作業は基本的に不要になります。
Babel/SWCプラグイン導入(Vite想定)
npm i -D babel-plugin-react-compiler
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const ReactCompilerConfig = {
compilationMode: "annotation", // "all" にすると全コンポーネント自動最適化
target: "19",
};
export default defineConfig({
plugins: [
react({
babel: {
plugins: [["babel-plugin-react-compiler", ReactCompilerConfig]],
},
}),
],
});
「use memo」ディレクティブで段階導入
// このコンポーネントだけCompilerで最適化される
function Dashboard({ items }: Props) {
"use memo";
const filtered = items.filter(i => i.active);
return <List items={filtered} />;
// ↑ ビルド後はfilteredが items変化時のみ再計算される
}
// 逆に最適化させたくないコンポーネント
function Volatile() {
"use no memo";
// ...
}
eslint-plugin-react-compiler で安全性を担保
// eslint.config.js
import reactCompiler from "eslint-plugin-react-compiler";
export default [
{
plugins: { "react-compiler": reactCompiler },
rules: {
"react-compiler/react-compiler": "error",
},
},
];
Compilerが最適化を諦める典型条件は「Rules of React違反」(mutationを行うコンポーネント、refの不正な書き換え、purityを破る副作用など)です。eslintルールでこれを開発時に検出できれば、Compilerの効果範囲を最大化できます。
Context最適化:Provider分割とselector
Context は「Provider配下の全consumerが、value変化のたびに再render」されます。1つの Context に user・theme・cart などを詰め込むと、cart更新で themeのconsumer まで全部renderされる悲劇が起きます。
Before:1つのContextに全部詰める
// ❌ どれか1つ変わると配下全部が再render
const AppContext = createContext<{
user: User; theme: Theme; cart: Cart;
setTheme: (t: Theme) => void; addToCart: (i: Item) => void;
} | null>(null);
After:関心ごとにContextを分割
// ✅ 値と更新関数すら別Contextにすると、更新関数のみconsumeする側が再renderしない
const UserContext = createContext<User | null>(null);
const ThemeStateContext = createContext<Theme>("light");
const ThemeDispatchContext = createContext<(t: Theme) => void>(() => {});
const CartStateContext = createContext<Cart>([]);
const CartDispatchContext = createContext<CartDispatch>(null!);
export const AppProviders = ({ children }: { children: React.ReactNode }) => {
const [theme, setTheme] = useState<Theme>("light");
const [cart, dispatch] = useReducer(cartReducer, []);
return (
<UserContext.Provider value={currentUser}>
<ThemeDispatchContext.Provider value={setTheme}>
<ThemeStateContext.Provider value={theme}>
<CartDispatchContext.Provider value={dispatch}>
<CartStateContext.Provider value={cart}>
{children}
</CartStateContext.Provider>
</CartDispatchContext.Provider>
</ThemeStateContext.Provider>
</ThemeDispatchContext.Provider>
</UserContext.Provider>
);
};
use-context-selector でフィールド単位購読
// 大きな1つのstoreでも、selector で「自分が使うフィールド」だけ購読
import { createContext, useContextSelector } from "use-context-selector";
type AppState = { user: User; theme: Theme; cart: Cart };
const AppCtx = createContext<AppState>(null!);
// cart の総額だけ購読する子。theme変更では再renderしない
export const CartTotal = () => {
const total = useContextSelector(AppCtx, s =>
s.cart.reduce((sum, i) => sum + i.price * i.qty, 0)
);
return <span>¥{total.toLocaleString()}</span>;
};
本格的にselector が欲しくなったら、Zustand や Jotai などのstore系ライブラリへ寄せた方が結局シンプルになります。
state の「持ち位置」を最適化する
setState が走るとそのコンポーネントとその子孫がrenderされます。stateは「使う場所の直近」に置くのが原則です。「上の方に置いて困らなければそれでよい」は誤りで、上に置けば置くほど巻き込み範囲が広がります。
Before:rootに不要なstateを持つ
// ❌ App全体がhover毎にrender
function App() {
const [hoveredId, setHoveredId] = useState<string | null>(null);
return (
<Layout>
<Sidebar />
<List items={items} hoveredId={hoveredId} onHover={setHoveredId} />
</Layout>
);
}
After:Listにstateを閉じる
// ✅ hoverはListの中だけで完結
function App() {
return (
<Layout>
<Sidebar />
<List items={items} />
</Layout>
);
}
function List({ items }: { items: Item[] }) {
const [hoveredId, setHoveredId] = useState<string | null>(null);
return <ul>{items.map(i =>
<Row key={i.id} item={i}
hovered={i.id === hoveredId}
onHover={() => setHoveredId(i.id)} />
)}</ul>;
}
「children」をpropsで受けて再render を遮断する
// ✅ ColorPicker は color が変わるが、childrenはAppがrenderしない限り変わらない
function App() {
return (
<ColorPicker>
<ExpensiveTree /> {/* color変化では再renderされない */}
</ColorPicker>
);
}
function ColorPicker({ children }: { children: React.ReactNode }) {
const [color, setColor] = useState("red");
return (
<div style={{ color }}>
<input value={color} onChange={e => setColor(e.target.value)} />
{children}
</div>
);
}
useEffect を減らす(派生stateの削除)
「propsからstateを作る」「stateからstateを作る」ためのuseEffectは、ほとんどが不要かつ二度renderの原因です。useEffectの使いどころを厳しく絞ることが、パフォーマンスの底上げに直結します。
Before:propsをstateにコピー
// ❌ user変化のたびに2回renderされる
function Profile({ user }: { user: User }) {
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${user.first} ${user.last}`);
}, [user]);
return <h1>{fullName}</h1>;
}
After:単に計算で導出する
// ✅ renderingの中で算出。stateもeffectも不要
function Profile({ user }: { user: User }) {
const fullName = `${user.first} ${user.last}`;
return <h1>{fullName}</h1>;
}
// 計算が重ければここでだけ useMemo
function Stats({ items }: { items: Item[] }) {
const total = useMemo(() => items.reduce((s, i) => s + i.price, 0), [items]);
return <b>¥{total}</b>;
}
イベントハンドラに移すべきもの
// ❌ submit成功"後"の処理を useEffect に書くと、stateが変わるたびに走る
useEffect(() => {
if (submitted) showToast("送信完了");
}, [submitted]);
// ✅ イベントハンドラに直接書く
async function onSubmit(values: FormValues) {
await api.submit(values);
showToast("送信完了");
}
データフェッチのキャッシュ:TanStack Query / SWR
同じデータを各画面でfetchし直す実装は、それ自体が性能問題です。TanStack Query や SWR はネットワークとキャッシュを抽象化し、staleなデータの再利用・重複排除・自動再検証を提供します。
TanStack Query の最小設定
// providers.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // 60秒は再fetchしない
gcTime: 5 * 60_000, // 5分でGC
refetchOnWindowFocus: false, // 必要に応じてON
retry: 1,
},
},
});
export const AppProviders = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
queryKey設計でキャッシュを効かせる
// keyを統一しておけば、画面横断で同じデータを使い回せる
export const userKeys = {
all: ["users"] as const,
detail: (id: string) => [...userKeys.all, "detail", id] as const,
list: (filter: Filter) => [...userKeys.all, "list", filter] as const,
};
const { data } = useQuery({
queryKey: userKeys.detail(id),
queryFn: ({ signal }) => fetchUser(id, signal),
enabled: !!id,
});
コード分割:React.lazy と Suspense
初期バンドルに「すぐは使わない画面のJS」を含めないことが、LCP/INPの両方を一気に改善します。React.lazy + Suspense で、ルート単位・モーダル単位で分割していきます。
ルート単位で分割
import { lazy, Suspense } from "react";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
const Dashboard = lazy(() => import("./routes/Dashboard"));
const Settings = lazy(() => import("./routes/Settings"));
const Reports = lazy(() => import("./routes/Reports"));
const router = createBrowserRouter([
{ path: "/", element: <Suspense fallback={<Spinner />}><Dashboard /></Suspense> },
{ path: "/settings", element: <Suspense fallback={<Spinner />}><Settings /></Suspense> },
{ path: "/reports", element: <Suspense fallback={<Spinner />}><Reports /></Suspense> },
]);
export const App = () => <RouterProvider router={router} />;
モーダル・チャート・エディタを遅延読み込み
// 重いリッチエディタは「開かれた時に初めて」読み込む
const RichEditor = lazy(() => import("./components/RichEditor"));
function NotePage() {
const [editing, setEditing] = useState(false);
return (
<>
<button onClick={() => setEditing(true)}>編集</button>
{editing && (
<Suspense fallback={<p>エディタ読込中...</p>}>
<RichEditor />
</Suspense>
)}
</>
);
}
プリフェッチでクリック時の遅延を消す
// hoverやviewport到達時にchunkを先読み
const preloadReports = () => import("./routes/Reports");
<Link to="/reports"
onMouseEnter={preloadReports}
onFocus={preloadReports}>
レポート
</Link>
仮想スクロール:1万件を60fpsで描く
1,000件を超えるリスト・グリッド・テーブルは、DOMノード数自体がボトルネックになります。仮想スクロールは「画面に映る分だけDOMを生成」する技法で、TanStack Virtual / react-window が現役の選択肢です。
TanStack Virtual:可変高さも対応
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";
function BigList({ rows }: { rows: Row[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virt = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
overscan: 8,
});
return (
<div ref={parentRef} style={{ height: 600, overflow: "auto" }}>
<div style={{ height: virt.getTotalSize(), position: "relative" }}>
{virt.getVirtualItems().map(v => (
<div
key={v.key}
style={{
position: "absolute", top: 0, left: 0,
width: "100%", height: v.size,
transform: `translateY(${v.start}px)`,
}}
>
{rows[v.index].label}
</div>
))}
</div>
</div>
);
}
react-window:固定高さで最小コスト
import { FixedSizeList as List } from "react-window";
const Row = ({ index, style, data }: any) => (
<div style={style}>{data[index].label}</div>
);
export const Grid = ({ rows }: { rows: Row[] }) => (
<List
height={600}
itemCount={rows.length}
itemSize={48}
itemData={rows}
width={"100%"}
>{Row}</List>
);
仮想化と無限スクロールを組み合わせる
// 表示中の末尾アイテムが「最後から5番目」に到達したら次ページfetch
const items = data?.pages.flatMap(p => p.items) ?? [];
const last = virt.getVirtualItems().at(-1);
useEffect(() => {
if (!last) return;
if (last.index >= items.length - 5 && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [last, items.length, hasNextPage, isFetchingNextPage]);
スタイリングのコスト:CSS-in-JS 削減
ランタイムCSS-in-JS(emotion / styled-components)はrender中にCSSを生成・挿入するため、コンポーネント数に比例してINPが悪化しがちです。2026年のベストプラクティスは「静的に解決できる手法へ寄せる」ことです。
| 手法 | ランタイムコスト | 動的スタイル | 採用しやすさ |
|---|---|---|---|
| Tailwind CSS | ほぼゼロ | className切替 | ◎ |
| CSS Modules | ほぼゼロ | className切替 | ◎ |
| vanilla-extract | ゼロ(ビルド時) | recipe / variants | ○ |
| Panda CSS | ゼロ(ビルド時) | パターン豊富 | ○ |
| emotion (runtime) | 高 | props駆動 | △(既存資産除く) |
Tailwindでprops駆動を吸収する
import { cva, type VariantProps } from "class-variance-authority";
const button = cva(
"inline-flex items-center rounded font-semibold transition",
{
variants: {
intent: { primary: "bg-blue-600 text-white hover:bg-blue-700",
ghost: "bg-transparent text-blue-700 hover:bg-blue-50" },
size: { sm: "px-2 py-1 text-sm", md: "px-3 py-1.5", lg: "px-4 py-2 text-lg" },
},
defaultVariants: { intent: "primary", size: "md" },
}
);
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>
& VariantProps<typeof button>;
export const Button = ({ intent, size, className, ...rest }: ButtonProps) => (
<button className={button({ intent, size, className })} {...rest} />
);
画像とフォントの最適化
LCPの主犯は多くの場合「ヒーロー画像」と「ヒーロー文字に使うフォント」です。優先度付き読込・サイズ予約・最新フォーマットの3点だけで、体感が劇的に変わります。
Next.js Image でLCPを最適化
import Image from "next/image";
<Image
src="/hero.webp"
alt="ヒーロー画像"
width={1280}
height={640}
priority // LCP対象なら必須(preloadヒントが入る)
sizes="(max-width: 768px) 100vw, 1280px"
placeholder="blur"
/>
素のimgでもやる気を出す
<picture>
<source srcSet="/hero.avif" type="image/avif" />
<source srcSet="/hero.webp" type="image/webp" />
<img
src="/hero.jpg"
width={1280} height={640} /* CLS対策に必ず指定 */
alt="hero"
fetchPriority="high" /* LCP画像はhigh */
decoding="async"
loading="eager" /* 折りたたみ上は eager / 下は lazy */
/>
</picture>
フォントは display:swap + 自前ホスト
/* index.css */
@font-face {
font-family: "Inter";
src: url("/fonts/inter-var.woff2") format("woff2");
font-display: swap; /* テキストを先に出す */
font-weight: 100 900;
}
Web Vitals 測定とINP/LCP/CLS対策
2024年以降、FIDに代わって INP(Interaction to Next Paint) が Core Web Vitals に正式採用されています。INPはクリック・タップ・キー入力に対する「次の描画」までの遅延を統合的に測る指標で、Reactの再render戦略がそのまま反映されます。
| 指標 | 意味 | Good | Needs improvement | Poor |
|---|---|---|---|---|
| LCP | 最大コンテンツ描画 | ≤2.5s | ≤4.0s | >4.0s |
| INP | 操作から描画反映 | ≤200ms | ≤500ms | >500ms |
| CLS | レイアウトずれ累積 | ≤0.1 | ≤0.25 | >0.25 |
web-vitals で本番計測
// src/perf.ts
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";
function send(metric: { name: string; value: number; id: string }) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
url: location.pathname,
});
navigator.sendBeacon("/api/vitals", body);
}
onLCP(send);
onINP(send);
onCLS(send);
onFCP(send);
onTTFB(send);
INP対策:重い同期処理を切り出す
// Before: クリック直後に重いfilterを同期実行→次の描画が500ms遅れる
const onClick = () => setQuery(input);
// After: useTransition で「緊急ではない更新」と分類
import { useTransition } from "react";
const [isPending, startTransition] = useTransition();
const onClick = () => {
startTransition(() => setQuery(input));
};
return (
<>
<input value={input} onChange={e => setInput(e.target.value)} />
{isPending && <Spinner />}
<BigList query={query} />
</>
);
useDeferredValue で重い派生表示を遅らせる
import { useDeferredValue, useState } from "react";
function Search() {
const [text, setText] = useState("");
const deferred = useDeferredValue(text); // 入力に追いつけなければスキップ
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<BigList query={deferred} />
</>
);
}
CLS対策:サイズ予約とfont-display
/* aspect-ratio で画像領域を事前確保 */
.hero { aspect-ratio: 16 / 9; width: 100%; background: #eee; }
/* 広告・埋め込み枠も最低高さを取る */
.ad-slot { min-height: 250px; }
バンドル解析と Tree Shaking
「初期JSが大きい」は、ほとんどがmoment.js / lodash 全import / 巨大なUIライブラリ全importで起きます。週1でバンドルを覗く習慣だけで、JSサイズの肥大を未然に止められます。
Vite で rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from "rollup-plugin-visualizer";
export default defineConfig({
plugins: [
react(),
visualizer({
filename: "dist/stats.html",
template: "treemap",
gzipSize: true,
brotliSize: true,
}),
],
});
Next.js で @next/bundle-analyzer
// next.config.mjs
import bundleAnalyzer from "@next/bundle-analyzer";
const withAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === "true" });
export default withAnalyzer({ reactStrictMode: true });
// 実行
// ANALYZE=true next build
Tree Shakingが効くimport
// ❌ ライブラリ全体が入る
import _ from "lodash";
_.debounce(fn, 200);
// ✅ サブモジュールimport(esmならどちらでも同じだが歴史的経緯で安全)
import debounce from "lodash/debounce";
debounce(fn, 200);
// ✅ さらに lodash-es に置き換える
import { debounce } from "lodash-es";
// ❌ momentは丸ごと重い
import moment from "moment";
// ✅ 軽量代替に乗り換え
import { format } from "date-fns/format";
sideEffects と modularizeImports
// package.json (自前パッケージ側)
{
"sideEffects": ["**/*.css"] /* CSS以外はsideEffectsなしと宣言 */
}
// next.config.mjs : 巨大UIライブラリのimportパスを自動最適化
export default {
modularizeImports: {
"@mui/icons-material": {
transform: "@mui/icons-material/{{member}}",
},
"lodash": {
transform: "lodash/{{member}}",
},
},
};
キャッシュ層:Service Worker と HTTP cache
2回目以降の訪問を「ほぼ瞬時」にできるかは、Service Worker(SW) と HTTP cache の設計次第です。SPA・PWAで安定運用するには、Workbox を素直に使うのが最も低コストです。
vite-plugin-pwa で Workbox を導入
// vite.config.ts
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
workbox: {
navigateFallback: "/index.html",
runtimeCaching: [
{
urlPattern: /^https://api.example.com/static//,
handler: "CacheFirst",
options: { cacheName: "api-static", expiration: { maxEntries: 100, maxAgeSeconds: 7 * 24 * 3600 } },
},
{
urlPattern: /^https://api.example.com//,
handler: "StaleWhileRevalidate",
options: { cacheName: "api-dynamic" },
},
],
},
}),
],
});
HTTPヘッダの基本セット
# /assets/*.[hash].js などの内容ハッシュ付き
Cache-Control: public, max-age=31536000, immutable
# /index.html のような毎回検証したいファイル
Cache-Control: no-cache
# /api/me などプライベートAPI
Cache-Control: private, no-store
Suspenseと並行レンダリングの実戦
React 18 / 19 のSuspenseは「データ取得・コード分割・画像」を統一的に扱えます。境界をどこに置くかで、ユーザー体験が「白画面で待つ」「触りながら段階表示される」のどちらにもなります。
境界を細かく置いて「触れる場所」を増やす
function DashboardPage() {
return (
<Layout>
{/* ヘッダはすぐ表示 */}
<Suspense fallback={<Skeleton h={64} />}>
<HeaderStats />
</Suspense>
<div className="grid grid-cols-2 gap-4">
{/* 左右独立に解決される */}
<Suspense fallback={<Skeleton h={300} />}>
<SalesChart />
</Suspense>
<Suspense fallback={<Skeleton h={300} />}>
<RecentOrders />
</Suspense>
</div>
</Layout>
);
}
useTransition で「次画面」を背景で準備
const [isPending, startTransition] = useTransition();
const navigate = useNavigate();
const onSelectUser = (id: string) => {
startTransition(() => {
navigate(`/users/${id}`); // 次画面の読み込み中も前画面が操作可能
});
};
SSR / RSC によるネットワーク削減
React Server Components(RSC)を使うと、サーバー側で実行されるコンポーネントの JS はクライアントに一切配送されません。Markdown→HTML変換のような重い処理を RSC に逃すだけで、バンドルとINPの両方が改善します。
Next.js App Router でRSCをデフォルト化
// app/articles/[id]/page.tsx — Server Component(JSはクライアントに出ない)
import { getArticle } from "@/lib/db";
import { MarkdownView } from "./markdown-view";
export default async function ArticlePage({ params }: { params: { id: string } }) {
const article = await getArticle(params.id);
return (
<article>
<h1>{article.title}</h1>
<MarkdownView source={article.body} />
</article>
);
}
// app/articles/[id]/markdown-view.tsx — これだけClient Componentに分離
"use client";
import { useState } from "react";
export const MarkdownView = ({ source }: { source: string }) => {
const [open, setOpen] = useState(false);
return open
? <div dangerouslySetInnerHTML={{ __html: source }} />
: <button onClick={() => setOpen(true)}>続きを読む</button>;
};
計測〜改善のワークフロー実例
個別テクニックを並べてもプロジェクトは速くなりません。「同じ手順を毎週まわす」工程化が重要です。実プロジェクトで再現性のあるフローは次の通りです。
- 本番のWeb Vitalsを収集(p75を見る)
- 悪化トップ3画面をPlaywright + Lighthouse CI で再現
- DevTools Profilerで「commit時間 > 16ms」のコミットを特定
- why-did-you-render で過剰renderコンポーネントを特定
- state持ち位置 / Context分割 / memo の順で修正
- バンドルアナライザで「あるはずのない大物」がいないか確認
- Lighthouse CI でしきい値超えたらCIを赤くする
Lighthouse CI でPR毎に計測
# .github/workflows/lhci.yml
name: lhci
on: [pull_request]
jobs:
lhci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci && npm run build
- run: npx @lhci/cli@0.13.x autorun
// lighthouserc.json
{
"ci": {
"collect": { "startServerCommand": "npm run preview", "url": ["http://localhost:4173/"] },
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"interactive": ["error", { "maxNumericValue": 3500 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }]
}
}
}
}
独学で詰まったら:質問できる環境を用意する
Reactの最適化は「自分のコードを誰かに見てもらう」が最短ルートです。Profilerのスクショだけで「ここがmemoが効かない理由」を即指摘してもらえる相手がいるかどうかで、習熟スピードが大きく変わります。
- テックアカデミー: 現役エンジニアによるマンツーマンメンタリング。Reactコース有り。
- 侍エンジニア: 完全オーダーメイドカリキュラム。「自社プロダクトのチューニング案件」を題材にできる。
- DMM WEBCAMP: 転職保証付。React+TypeScript+Next.jsのモダン構成を扱う。
- レバテックキャリア: パフォーマンス改善案件・SREに近いフロント案件を扱う転職エージェント。
独学で「memoを貼っても速くならない」と止まっている人は、まずプロにProfilerの結果を見せて「原因のあたり付け」だけ教わると、その後の自走スピードが段違いになります。
よくある質問(FAQ)
Q1. React Compilerを入れたら手動の memo / useMemo / useCallback は全部消して良い?
原則「消して良い」です。ただし(a) コンポーネント外で生成したオブジェクトを props で渡している、(b) ref に格納した非React値などはCompilerが追跡できず、最適化を諦めます。eslintルールで「最適化されなかった理由」が警告されるので、まず警告ゼロにしてから不要な手動メモ化を外すのが安全です。
Q2. 「再renderされる=遅い」と思って良い?
いいえ。Reactのrender自体は非常に高速で、commit時間が16ms未満ならまったく問題ありません。「再renderが多い」より「1コミットの時間が長い」「INPが大きい」を直接見るべきです。
Q3. リストが重いです。最初に何をすべき?
件数で分岐します。<100件なら memo + key 安定化、100〜1,000件なら memo + 状態分離、1,000件超は仮想スクロール一択です。memoより仮想化の方が桁違いに効くので、迷ったら件数を先に確認します。
Q4. Context vs Zustand、どちらが速い?
素のContextは「Provider配下全員に通知」されるため、頻繁に変わる値には向きません。Zustandは「selectorで購読」なので、変更したフィールドを使うコンポーネントだけが再renderされます。Zustandの基本を覚えると、Context最適化の8割は不要になります。
Q5. INPがどうしても下がりません。
INPの主犯は「クリック直後に走る重いJS」か「サードパーティスクリプト」のどちらかです。Performanceタブの「Long Tasks」「Total Blocking Time」を確認し、(1) useTransition / useDeferredValue で更新を分割、(2) GA・広告・チャットボットを idle 後に遅延注入、の順で改善します。
Q6. SSRすると速くなりますか?
LCPとSEOには非常に効きますが、INPはむしろ悪化することがあります(hydration分のJSが増えるため)。Next.js App Router/RSCのように「クライアントに送るJSを最小化」できる構成と組み合わせて初めて、SSRが全体最適になります。
Q7. パフォーマンス改善はどこまでやれば良い?
「Core Web Vitalsの3指標がすべて Good」「主要操作のINPがp75で200ms以下」「初期JSがgzip後150KB以下」を一旦のゴールに置くと現実的です。これを超えても収益への限界効用は急減します。ビルドツール側の最適化と合わせて、定期計測で維持する運用に移行しましょう。
まとめ:測って・削って・分けて・遅らせる
- 測って:DevTools Profiler / web-vitals / why-did-you-render で原因をデータで特定
- 削って:state持ち位置の見直し、不要なuseEffect削除、Context分割で再render自体を減らす
- 分けて:React.lazy + Suspense でルート/モーダル/重いコンポーネントを遅延読込、RSCでサーバー側に逃す
- 遅らせる:useTransition / useDeferredValue / 仮想スクロールで「画面に映る分だけ」描画
- 自動化:React Compilerで手動memoを卒業、Lighthouse CIで指標を維持
Reactのパフォーマンス改善は「個々のテクニック」より「測定→改善→計測の閉ループ」を文化として定着させる方が遥かに効きます。本記事のコードはそのままコピーして良いように仕上げてあるので、まずは Profiler と web-vitals の2つを今日のうちにプロジェクトへ仕込み、来週から「指標で会話できるチーム」を作っていきましょう。

コメント