React.lazyとdynamic importでコード分割:重いUIを後回しにする
トップページが管理画面やグラフまで一緒に読み込んで重い。React.lazy+Suspense、Next.jsのdynamic、プリフェッチ、分割しすぎの罠まで、コピペで動く実装で送り分けを解説。
トップページを開いただけなのに、表示まで妙に待たされる。DevToolsのNetworkを見たら、誰も押していない管理画面、まだスクロールしてもいない売上グラフ、開いてもいないリッチエディタのJavaScriptまで、最初の1秒で全部ダウンロードしていました。
読者が見たいのは見出しと本文だけ。なのに、ページ全体のコードを人質に取られて、肝心のファーストビューが出てこない。
これ、僕が自分のサイトでやらかしていた状態そのものです。原因は「全部を1つのファイルにまとめて配っていた」こと。直し方はシンプルで、重いUIを最初から配るのをやめて、必要になった瞬間に取りに行く。これがコード分割と遅延読み込みです。今日はReactのlazyとNext.jsのdynamicを軸に、コピペで動く形で組み立てていきます。
この記事の要点
- コード分割は「大きなJavaScriptを小さなかたまり(chunk)に割る」、遅延読み込みは「そのかたまりを必要なときに取りに行く」こと。旅行のたびに家中の荷物を運ばず、現地で使う分だけ持つ発想です。
- 削除ではなく後回しです。使わないコードを永久に消すのは tree shaking の仕事で、役割が違います(tree shakingでバンドルサイズを削るへ送り分け)。
- Reactは
React.lazy + Suspense、Next.jsはnext/dynamic。どちらもモジュールのトップレベルで宣言するのが鉄則です。 - クリックしてから読み込むと一瞬カクつく。これはプリフェッチ(先読み)で消せます。
- 分割しすぎると逆に遅くなります。chunkが増えるほどネットワーク往復が増えるからです。
分割していい部品、ダメな部品
最初に線を引きます。何でも遅延読み込みすればいいわけではありません。狙うのは「最初の表示に要らない・サイズが大きい・操作後に初めて要る」部品です。逆にファーストビューの見出し、本文、CTA(申込ボタン)、ナビは触りません。ここを後回しにすると、読者も検索エンジンも肝心の情報を最初に見られなくなります。
| 対象 | 後回しにしやすい理由 | 注意点 |
|---|---|---|
| 管理画面・設定画面 | 一般読者には不要 | 認証後の待ち時間を短く |
| グラフ・地図・エディタ | 依存ライブラリが重い | 表示枠を固定してガタつきを防ぐ |
| モーダル・ステップフォーム | クリック後にだけ要る | 初回が遅いなら先読みする |
| 動画・検索UI | 初期表示と関係が薄い | 代替テキストとエラー文言を入れる |
判断に迷ったら自分にこう聞きます。「これ、ページを開いた瞬間に画面に映っている?」映っていないなら後回しの候補です。
React.lazyとSuspenseの最小実装
Reactの公式ドキュメントは、lazyでコンポーネントの読み込みを初回レンダリングまで遅らせ、待っている間はSuspenseのfallbackを出す、という流れです。ひとつだけ絶対に守ること。lazyはコンポーネントの外、モジュールのトップレベルで宣言する。 公式も「Do not declare lazy components inside other components」と明言しています。中で宣言すると、再レンダリングのたびに状態がリセットされ、毎回読み直しになります。
ViteでもCRAでも、Reactアプリならそのまま試せる最小構成です。
// src/App.tsx
import { Suspense, lazy, useState } from "react";
// ✅ トップレベルで宣言。コンポーネントの外に置く
const ReportsPanel = lazy(() => import("./ReportsPanel"));
function PanelSkeleton() {
// 高さを先に確保しておくと、表示時にレイアウトがガタつかない
return (
<div role="status" aria-live="polite" style={{ minHeight: 180 }}>
レポートを読み込んでいます...
</div>
);
}
export default function App() {
const [showReports, setShowReports] = useState(false);
return (
<main>
<h1>ダッシュボード</h1>
<button type="button" onClick={() => setShowReports(true)}>
レポートを表示
</button>
{showReports ? (
<Suspense fallback={<PanelSkeleton />}>
<ReportsPanel />
</Suspense>
) : (
<p>必要なときだけ重いレポートUIを読み込みます。</p>
)}
</main>
);
}
// src/ReportsPanel.tsx
const rows = [
{ label: "記事読了", value: "68%" },
{ label: "CTAクリック", value: "4.2%" },
{ label: "相談フォーム到達", value: "1.1%" },
];
export default function ReportsPanel() {
return (
<section aria-label="レポート">
<h2>コンバージョンレポート</h2>
<ul>
{rows.map((row) => (
<li key={row.label}>
{row.label}: {row.value}
</li>
))}
</ul>
</section>
);
}
ここで地味に詰まるのがlazyはdefault exportしか受け取らない点です。export function BarChart()のような名前付きexportをそのまま渡すと動きません。そういうときは読み込んだあとにdefaultへ詰め替える小さなヘルパーを噛ませます。
// src/lazyNamed.tsx
import { lazy, type ComponentType } from "react";
// 名前付きexportのモジュールを lazy で読めるように default へ詰め替える
export function lazyNamed<TModule, TName extends keyof TModule>(
loader: () => Promise<TModule>,
name: TName
) {
return lazy(async () => {
const mod = await loader();
return { default: mod[name] as ComponentType };
});
}
// 使い方:
// const BarChart = lazyNamed(() => import("./charts"), "BarChart");
Next.jsのdynamicはssr:falseの扱いがキモ
Next.jsはページ単位の分割をフレームワークが勝手にやってくれます。その上で「重いクライアント部品だけを遅らせたい」ときにnext/dynamicを使います。import()をdynamic()の中に書き、これもトップレベルで宣言。レンダリング中の条件分岐でdynamic()を呼ぶのは避けます。
ブラウザAPIに依存するリッチエディタのような部品は、"use client"を付けたClient Component側で切り出します。
// app/admin/EditorSlot.tsx
"use client";
import dynamic from "next/dynamic";
const RichEditor = dynamic(() => import("./RichEditor"), {
ssr: false, // ブラウザ専用UI。サーバーでは描画しない
loading: () => (
<p aria-live="polite" style={{ minHeight: 160 }}>
エディタを読み込んでいます...
</p>
),
});
export default function EditorSlot() {
return <RichEditor initialMarkdown="# Draft" />;
}
ここで注意。Next.js(App Router)では**ssr: falseはClient Componentの中でしか使えません**。公式ドキュメントにも「ssr: false is not allowed with next/dynamic in Server Components」と書かれていて、Server Componentに置くとビルドが落ちます。"use client"を付けたファイルへ逃がす、と覚えておけば事故りません。
そしてssr: falseは便利だからと乱用しないこと。これを付けるとサーバーHTMLに出力されなくなるので、SEOで読ませたい本文や商品説明には絶対に使いません。管理画面、プレビュー、ブラウザ専用エディタのような「検索に出さないUI」に限定します。
なお名前付きexportの読み込みは、Reactと違ってNext.jsは.then()で取り出せます。
// 名前付きexport Hello を dynamic で読む
import dynamic from "next/dynamic";
const Hello = dynamic(() =>
import("../components/hello").then((mod) => mod.Hello)
);
ルート単位でまず割る、コンポーネントは後
コンポーネント単位でいきなり考えると、粒度が細かくなりすぎて管理しきれません。最初に効くのはルート単位の分割です。Next.jsならページを分けるだけで自然に効きます。React Routerを使うSPAなら、ルートに対応するコンポーネントをlazyにします。
// src/AppRouter.tsx
import { Suspense, lazy } from "react";
import { BrowserRouter, Link, Route, Routes } from "react-router-dom";
const HomePage = lazy(() => import("./pages/HomePage"));
const ReportsPage = lazy(() => import("./pages/ReportsPage"));
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
export default function AppRouter() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link>
<Link to="/reports">Reports</Link>
<Link to="/settings">Settings</Link>
</nav>
{/* Suspenseは「画面全体」ではなく差し替わる中身だけを包む */}
<Suspense fallback={<p aria-live="polite">ページを読み込んでいます...</p>}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
ここで僕が最初にやらかしたのが、Suspenseでアプリ全体を包んでしまったこと。1個の遅い部品に引きずられて、ナビも見出しも含めた画面まるごとがローディング表示になりました。Suspenseは差し替わる中身だけを局所的に包む。見出しやCTAは通常表示のまま残す。これだけで体感がかなり安定します。ローディング中の見せ方をもう一段よくするなら、枠だけ先に出すスケルトンスクリーンの実装が相性いいです。
クリックがカクつくならプリフェッチで先読み
遅延読み込みの弱点は、ユーザーが操作した「その瞬間」に取りに行くこと。回線が細いと、ボタンを押してから表示まで一瞬固まります。これを消すのがプリフェッチ(先読み)。「クリックされるかも」というタイミング、たとえばボタンにマウスが乗った瞬間にchunkだけ裏で読み込んでおき、実際のクリック時には表示するだけにします。
下のコードはそのまま動きます。動的import()は同じモジュールに対しては結果がキャッシュされるので、何度prefetchを呼んでもダウンロードは1回だけです。
// src/ReportsButton.tsx
import { Suspense, lazy, useState, useCallback } from "react";
// 読み込み関数を1つにまとめ、lazyと先読みの両方から使い回す
const loadReports = () => import("./ReportsPanel");
const ReportsPanel = lazy(loadReports);
export default function ReportsButton() {
const [open, setOpen] = useState(false);
// hoverやfocusで「使われそう」と分かった時点で裏読み
const prefetch = useCallback(() => {
loadReports(); // import()はキャッシュされるので何度呼んでもDLは1回
}, []);
return (
<div>
<button
type="button"
onMouseEnter={prefetch}
onFocus={prefetch}
onClick={() => setOpen(true)}
>
レポートを表示
</button>
{open && (
<Suspense fallback={<p aria-live="polite">読み込み中...</p>}>
<ReportsPanel />
</Suspense>
)}
</div>
);
}
ポイントはimport()の式をloadReportsという関数に切り出して、lazyとprefetchの両方から同じものを呼ぶこと。別々に書くとバンドラが別chunkだと誤認することがあるので、参照を1本化しておくと安全です。
効く場面を具体で3つ
1. SaaSの管理ダッシュボード。 一般ユーザーが毎回開くホームと、管理者しか見ない売上グラフ・監査ログ・CSV出力を分けます。グラフライブラリや巨大な表コンポーネントは重くなりがちなので、権限チェックが通ってから読み込む設計が向いています。
2. ブログや教材サイトの編集画面。 公開記事の本文・見出し・CTAは即表示し、Markdownエディタ・プレビュー・画像トリミングだけを後回しにします。読者向けページは軽く保ち、編集者向け機能を別chunkへ逃がす。読者と書き手で必要なコードが違うサイトほど効きます。
3. 地図や動画を含むランディングページ。 ファーストビューで価値提案とCTAを見せ、下までスクロールした段階で地図や動画プレーヤーを読み込みます。動画UIを扱うなら動画プレーヤーの実装、キーボード操作や読み上げ対応はアクセシビリティ実装も合わせて見ておくと、後追いの手戻りが減ります。
僕がハマった落とし穴
正直に書くと、最初のコード分割は逆に遅くなりました。原因はひとつずつ潰せます。
割りすぎ。 「細かく分ければ速い」と思い込んで、小さな部品まで全部chunkにしたら、ページを開くたびに十数本のリクエストが飛び、ネットワークの往復待ちで体感が悪化しました。今は「最初の表示に要らない・依存が重い・操作後に要る」の3条件がそろう部品だけに絞っています。初期bundleが何KB減ったかだけでなく、追加リクエスト数と操作後の待ち時間も一緒に見ないと判断を誤ります。分割の効果測定そのものはバンドル分析の自動化に寄せると楽です。
fallbackが雑。 Loading...の一言だけ出していた頃は、どこが読み込み中なのか読者に伝わらず、しかも表示時に高さが変わってレイアウトがガタッと飛びました。Suspenseのfallbackは高さを先に確保し、aria-liveを付け、何を待っているか一言添える。これだけで「壊れた?」という誤解が消えます。
hydration mismatch。 サーバーでは「未ログイン」、ブラウザでは「ログイン済み」。あるいはサーバーとクライアントで時刻や乱数が食い違う。こういう表示のズレがあると、hydration errorが出ます。window参照・時刻・乱数・localStorageはサーバーで触らず、useEffectのあとで読むかClient Componentへ閉じ込めます。ssr: falseが役立つのもまさにここです。
収益導線まで後回しにした。 一度、価格表示とFAQをssr: falseの中に入れてしまい、検索結果にも出ず読者の初手にも映らず、という最悪の状態を作りました。収益やSEOに近いUIほど、遅延読み込みの対象から外す。これは原則として固定です。
よくある質問
Q. コード分割と tree shaking は何が違うんですか? A. コード分割は「使うけど後で要るコードを別chunkに分けて、必要なときに読み込む」=後回し。tree shakingは「どこからも使われていないコードをビルド時に消す」=削除です。目的が別物なので両方やります。削除側の話はtree shakingでバンドルサイズを削るにまとめています。
Q. React.lazyとnext/dynamic、どっちを使えばいい?
A. 素のReact(Vite/CRA)ならReact.lazy + Suspense。Next.jsなら基本はnext/dynamicです。loadingオプションやSSR制御が組み込みで、ssr: falseまで指定できるのがdynamicの利点です。
Q. ssr: falseはいつ付けるの?
A. windowやdocumentなどブラウザ専用APIに依存していて、サーバーで描画する意味がないUIに付けます。逆にSEOで読ませたい本文・価格・CTAには付けません。App Routerでは"use client"なファイルの中でしか使えない点に注意です。
Q. クリック後の一瞬のカクつきが気になります。
A. プリフェッチで消せます。ボタンのonMouseEnterやonFocusでchunkを裏読みしておけば、実クリック時には表示するだけになります。本記事のプリフェッチ例がそのまま使えます。
Q. どこまで分割すれば正解ですか? A. 「初期JSが減ったか」と「追加リクエストが増えすぎていないか」を両方見て、操作後の待ち時間が許容内に収まる範囲が正解です。chunkが二桁本に膨らんでいたら、たいてい割りすぎです。
実際に試した結果
この手順で、自分のサイトの重いレポートUIとエディタを初期表示から外してみました。効果が出たのは、コードを書く前に「ファーストビューとCTAは残す」「依存が重い部品だけ分ける」「Networkでchunkを確認する」を先に決めた回です。プリフェッチを足してからは、ボタンを押した瞬間のカクつきもほぼ消えました。
逆に、Claude Codeへ何も決めずに「遅延読み込みして」とだけ投げた回は、Suspenseの範囲が広すぎて画面全体がローディングになったり、要らない場所にssr: falseが入ったりしました。コード分割は魔法ではなく、読者が最初に必要とする情報を守るための設計作業です。割る前に「最初の画面に映っているか?」を一度問う。それだけで結果がだいぶ変わります。
レビュー観点を毎回書くのが面倒なら、実装・レビュー・パフォーマンス検証の型をまとめた教材一覧から始めるのが近道です。チームで既存Next.jsアプリの速度改善やCLAUDE.md整備まで詰めたいときは、研修・相談で実リポジトリ前提の進め方を相談できます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。