Reactパフォーマンス最適化完全ガイド〜React Compiler・memo化・コード分割・仮想スクロール【2026年版】〜

Reactの「描画は速いがアプリは重い」という現象は、99%が再レンダリングの設計ミスネットワーク・バンドル設計の甘さに起因します。本記事では React 19 / React Compiler 時代の最適化戦略を、計測 → 再レンダリング削減 → コード分割 → 仮想化 → Web Vitals 改善 という順で、コピペで動くコードを軸に総整理します。

対象読者は React.memo / useMemo / useCallback を使ってはいるが「効いているのか分からない」「貼ったら逆に遅くなった」と感じている20〜40代の現役Webエンジニアです。「測ってから直す」を一貫した方針として進めます。

  1. パフォーマンス最適化の全体像と優先順位
  2. 計測ファースト:React DevTools Profiler
    1. Profilerタブで「なぜrenderした」を見る
    2. 本番でも軽く計測する
    3. why-did-you-render で過剰renderを炙り出す
  3. React.memoの正しい使い方と落とし穴
    1. Before:memoが効いていない典型
    2. After:参照を安定化する
    3. 比較関数(areEqual)を渡すパターン
    4. memoを貼るべきでないケース
  4. useMemo / useCallback を「正しく」使う
    1. useMemo:重い計算をスキップ
    2. useCallback:memo化された子に関数を渡すとき
    3. useEvent パターン(最新ref経由でラッチ)
  5. React Compiler:メモ化の自動化(2026年現在)
    1. Babel/SWCプラグイン導入(Vite想定)
    2. 「use memo」ディレクティブで段階導入
    3. eslint-plugin-react-compiler で安全性を担保
  6. Context最適化:Provider分割とselector
    1. Before:1つのContextに全部詰める
    2. After:関心ごとにContextを分割
    3. use-context-selector でフィールド単位購読
  7. state の「持ち位置」を最適化する
    1. Before:rootに不要なstateを持つ
    2. After:Listにstateを閉じる
    3. 「children」をpropsで受けて再render を遮断する
  8. useEffect を減らす(派生stateの削除)
    1. Before:propsをstateにコピー
    2. After:単に計算で導出する
    3. イベントハンドラに移すべきもの
  9. データフェッチのキャッシュ:TanStack Query / SWR
    1. TanStack Query の最小設定
    2. queryKey設計でキャッシュを効かせる
  10. コード分割:React.lazy と Suspense
    1. ルート単位で分割
    2. モーダル・チャート・エディタを遅延読み込み
    3. プリフェッチでクリック時の遅延を消す
  11. 仮想スクロール:1万件を60fpsで描く
    1. TanStack Virtual:可変高さも対応
    2. react-window:固定高さで最小コスト
    3. 仮想化と無限スクロールを組み合わせる
  12. スタイリングのコスト:CSS-in-JS 削減
    1. Tailwindでprops駆動を吸収する
  13. 画像とフォントの最適化
    1. Next.js Image でLCPを最適化
    2. 素のimgでもやる気を出す
    3. フォントは display:swap + 自前ホスト
  14. Web Vitals 測定とINP/LCP/CLS対策
    1. web-vitals で本番計測
    2. INP対策:重い同期処理を切り出す
    3. useDeferredValue で重い派生表示を遅らせる
    4. CLS対策:サイズ予約とfont-display
  15. バンドル解析と Tree Shaking
    1. Vite で rollup-plugin-visualizer
    2. Next.js で @next/bundle-analyzer
    3. Tree Shakingが効くimport
    4. sideEffects と modularizeImports
  16. キャッシュ層:Service Worker と HTTP cache
    1. vite-plugin-pwa で Workbox を導入
    2. HTTPヘッダの基本セット
  17. Suspenseと並行レンダリングの実戦
    1. 境界を細かく置いて「触れる場所」を増やす
    2. useTransition で「次画面」を背景で準備
  18. SSR / RSC によるネットワーク削減
    1. Next.js App Router でRSCをデフォルト化
  19. 計測〜改善のワークフロー実例
    1. Lighthouse CI でPR毎に計測
  20. 独学で詰まったら:質問できる環境を用意する
  21. よくある質問(FAQ)
    1. Q1. React Compilerを入れたら手動の memo / useMemo / useCallback は全部消して良い?
    2. Q2. 「再renderされる=遅い」と思って良い?
    3. Q3. リストが重いです。最初に何をすべき?
    4. Q4. Context vs Zustand、どちらが速い?
    5. Q5. INPがどうしても下がりません。
    6. Q6. SSRすると速くなりますか?
    7. Q7. パフォーマンス改善はどこまでやれば良い?
  22. まとめ:測って・削って・分けて・遅らせる

パフォーマンス最適化の全体像と優先順位

最適化に飛びつく前に、効果が大きい順で投資先を決めるべきです。実プロダクトで効くのは「測定 → ネットワーク/バンドル削減 → 再レンダリング削減 → メモ化 → 仮想化」の順で、これは Lighthouse スコアとも素直に一致します。

レイヤ主な指標代表施策効果規模
計測Profiler / Web VitalsDevTools / web-vitals前提
ネットワークLCP / TTFBSSR・CDN・画像最適化非常に大
バンドルJSサイズcode splitting / tree shaking
レンダリングINP / commit時間state分割 / memo / Compiler中〜大
大量描画commit時間仮想スクロール大(リスト系のみ)
レイアウトCLSサイズ予約・font-display

memoを貼れば速くなる」という幻想を捨て、まず本当に再レンダリングが原因かを Profiler で確認するところから始めます。useMemouseCallbackのミクロな違いは本記事では深追いせず、本稿はマクロな最適化戦略に集中します。

計測ファースト: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戦略がそのまま反映されます。

指標意味GoodNeeds improvementPoor
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>;
};

計測〜改善のワークフロー実例

個別テクニックを並べてもプロジェクトは速くなりません。「同じ手順を毎週まわす」工程化が重要です。実プロジェクトで再現性のあるフローは次の通りです。

  1. 本番のWeb Vitalsを収集(p75を見る)
  2. 悪化トップ3画面をPlaywright + Lighthouse CI で再現
  3. DevTools Profilerで「commit時間 > 16ms」のコミットを特定
  4. why-did-you-render で過剰renderコンポーネントを特定
  5. state持ち位置 / Context分割 / memo の順で修正
  6. バンドルアナライザで「あるはずのない大物」がいないか確認
  7. 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つを今日のうちにプロジェクトへ仕込み、来週から「指標で会話できるチーム」を作っていきましょう。

コメント

タイトルとURLをコピーしました