- React
React Suspenseとlazyの使い方|コード分割とuseフック対応
React の lazy は「コンポーネントのコードを、必要になったときに読み込む」ための関数で、Suspense は「読み込みが終わるまで代わりの表示(fallback)を出す」ためのコンポーネントです。 この2つを組み合わせると、最初に読み込むJavaScriptを小さくできます。これがコード分割です。
この記事では React 19.3.0 と Vite 8.3.2 を使い、分割前後のビルド出力、fallback が切り替わる時刻、エラーになる例の実際のメッセージを載せています。React 19 で加わった use によるデータの読み込み、ErrorBoundary との組み合わせ、デプロイ後に出る「Failed to fetch dynamically imported module」の再現と対処まで、1ページで確認できます。
Suspenseとlazyの早見表
| やりたいこと | 書き方 | ポイント |
|---|---|---|
| コンポーネントを遅延読み込みにする | const Report = lazy(() => import("./Report")) |
コンポーネントの外(モジュールの先頭)で定義する |
| 読み込み中の表示を出す | Suspense fallback={...} で囲む |
lazy のコンポーネントは必ず Suspense の内側に置く |
| 名前付きエクスポートを読み込む | lazy(() => import("./Charts").then((m) => ({ default: m.BarChart }))) |
lazy が受け取るのは default を持つオブジェクト |
| Promise のデータを読む | const items = use(promise) |
Promise はコンポーネントの外か親で作り、同じものを渡す |
| エラーを受け止める | ErrorBoundary(クラス)で Suspense を囲む | ErrorBoundary を外側、Suspense を内側にする |
| 届いた部分から順に表示する | Suspense を入れ子にする | 内側の fallback は、外側の中身が表示されてから出る |
| デプロイ後のチャンク読み込みエラーに備える | 1回だけ再読み込みする lazy + ErrorBoundary | 以前のチャンクをしばらく残す方法もある |
確認した環境
| 項目 | バージョン |
|---|---|
| react / react-dom | 19.3.0 |
| Vite / @vitejs/plugin-react | 8.3.2 / 6.1.1 |
| TypeScript | 7.0.2 |
| Node.js | 22.22.2 |
| ブラウザ | Chromium(Playwright 1.56.0) |
実行結果は、すべてこの環境でビルドしてChromiumで開いたものです(ほかのブラウザでは未実行)。時刻(ms)は手元での計測値で、環境によって前後します。
lazyとSuspenseの最小コード
タブで「ホーム・レポート・設定」を切り替える小さなアプリを例にします。レポートは3,000件のデータ(確認用に生成したもの、約140 kB のソース)を読み込む、重めの画面という想定です。
src/Report.tsx(遅延読み込みされる側。default export にしておく)
import { rows } from "./rows";
export default function Report() {
const total = rows.reduce((sum, row) => sum + row.stock, 0);
return (
<section>
<h1>レポート</h1>
<p id="total">{rows.length} 件、在庫合計 {total}</p>
</section>
);
}
src/App.tsx
import { lazy, Suspense, useState } from "react";
import Home from "./Home";
// 動的 import を lazy に渡す。モジュールのトップレベルで 1 回だけ定義する
const Report = lazy(() => import("./Report"));
const Settings = lazy(() => import("./Settings"));
type Tab = "home" | "report" | "settings";
export default function App() {
const [tab, setTab] = useState<Tab>("home");
return (
<div>
<nav>
<button onClick={() => setTab("home")}>ホーム</button>
<button onClick={() => setTab("report")}>レポート</button>
<button onClick={() => setTab("settings")}>設定</button>
</nav>
{/* 読み込みが終わるまで fallback を表示する */}
<Suspense fallback={<p id="fallback">読み込み中…</p>}>
{tab === "home" && <Home />}
{tab === "report" && <Report />}
{tab === "settings" && <Settings />}
</Suspense>
</div>
);
}
書き方の決まりは3つです。
- lazy にはコンポーネントそのものではなく、「動的 import を返す関数」を渡します。
import("./Report")は呼ばれた時点で読み込みが始まるので、関数で包んで「描画されるときに呼ぶ」ようにします。 - lazy の定義はコンポーネントの外に置きます。 コンポーネントの中で定義すると、描画のたびに別のコンポーネントとして扱われ、状態が保たれません。
- lazy のコンポーネントは Suspense の内側で使います。 読み込み中は、いちばん近い Suspense の fallback が表示されます。
コード分割の前後でチャンクはどう変わるか
同じ画面を、ふつうの import で書いた場合(分割前)と lazy で書いた場合(分割後)でビルドしました。
分割前(import Report from "./Report")の vite build
before/dist/index.html 0.25 kB │ gzip: 0.21 kB
before/dist/assets/index-C3WjbJ0Q.js 329.86 kB │ gzip: 85.45 kB
分割後(lazy)の vite build
after/dist/index.html 0.25 kB │ gzip: 0.21 kB
after/dist/assets/Settings-CYD1qajF.js 0.12 kB │ gzip: 0.14 kB
after/dist/assets/Report-D1j-LRou.js 109.87 kB │ gzip: 15.48 kB
after/dist/assets/index-BQ9VjECI.js 221.62 kB │ gzip: 69.42 kB
| 比べる点 | 分割前 | 分割後 |
|---|---|---|
| 最初に読み込む JS | 329.86 kB(gzip 85.45 kB) | 221.62 kB(gzip 69.42 kB) |
| レポートを開いたときに追加で読む JS | なし | 109.87 kB(gzip 15.48 kB) |
| ファイル数(JS) | 1 | 3 |
最初に読み込む量が約108 kB 減り、そのぶんは「レポート」を開いたときに初めて読み込まれます。 合計の大きさはほぼ同じで、読み込むタイミングを後ろにずらしているわけです。設定画面のように小さいもの(0.12 kB)は分けても効果が小さいので、分ける対象は「大きくて、最初の画面では使わないもの」に絞るのがコツです。
| 分けると効果が出やすいもの | 分けなくてよいもの |
|---|---|
| ページ単位の画面(ルートごと) | 最初の画面に必ず出る部品 |
| グラフ・エディタ・地図など大きなライブラリを使う部品 | ボタンやアイコンなど小さな部品 |
| 開く人が限られる管理画面やモーダル | どの画面でも使う共通レイアウト |
fallbackが表示される流れ
分割後のアプリで「レポート」を押したときの動きを、開発者ツールと同じ情報(ネットワークと画面の変化)で追うと次のようになります。
sequenceDiagram
participant U as 利用者
participant R as React
participant S as サーバー
U->>R: 「レポート」をクリック
R->>S: Report-xxxx.js を取りに行く
R-->>U: fallback(読み込み中…)を表示
S-->>R: チャンクが届く
R-->>U: レポートの画面に切り替え
別の小さな例(後述の棒グラフ)で、画面が変わった時刻を記録しました。
7ms: グラフを読み込み中…
305ms: 棒グラフ
チャンクは 0.22 kB と小さく、同じマシン上のサーバーからすぐ届いていますが、切り替わりは約300ms後でした。今回試した範囲では、一度 fallback が出ると、中身が早く届いても約300ms は fallback が表示され続けました(ほかの例でも 305〜315ms)。ごく短い読み込みで画面がちらつくのを抑える動きと考えられます。fallback には、一瞬で消えても違和感のない控えめな表示(スケルトンや小さなスピナー)が向いています。
名前付きエクスポートを読み込む方法
lazy に渡す関数は、default プロパティにコンポーネントが入ったオブジェクトを返す必要があります。export function BarChart のような名前付きエクスポートだけのモジュールは、then で形をそろえます。
src/Charts.tsx
// default export を持たず、名前付きエクスポートだけのモジュール
export function BarChart() {
return <p id="chart">棒グラフ</p>;
}
export function LineChart() {
return <p>折れ線グラフ</p>;
}
src/NamedDemo.tsx
import { lazy, Suspense } from "react";
// then で { default: ... } の形に変換してから lazy に渡す
const BarChart = lazy(() =>
import("./Charts").then((module) => ({ default: module.BarChart })),
);
export default function NamedDemo() {
return (
<Suspense fallback={<p>グラフを読み込み中…</p>}>
<BarChart />
</Suspense>
);
}
実行結果は上の「グラフを読み込み中… → 棒グラフ」のとおりです。
そのまま渡すとどうなるか
lazy(() => import("./Charts")) と書いた場合、TypeScript では型チェックの時点で気づけます(tsc --noEmit の出力。パスは短くしています)。
error TS2322: Type 'Promise<typeof import(".../demo/src/Charts")>' is not assignable to type 'Promise<{ default: ComponentType<any>; }>'.
Property 'default' is missing in type 'typeof import(".../demo/src/Charts")' but required in type '{ default: ComponentType<any>; }'.
型チェックを通さずに動かすと、ErrorBoundary には次のメッセージが届きました。
| ビルド | ErrorBoundary に届いたメッセージ |
|---|---|
開発サーバー(vite) |
Cannot convert object to primitive value |
本番用ビルド(vite build) |
Minified React error #306; visit https://react.dev/errors/306?args[]=undefined&args[]= for the full message ... |
エラー番号 306 の全文は「Element type is invalid. Received a promise that resolves to: %s. Lazy element type must resolve to a class or function.%s」です(react.dev のエラー一覧ページで確認)。どちらのメッセージも「lazy が受け取ったものがコンポーネントではなかった」という意味なので、default の有無を見直すと解決します。もう1つの方法として、export { BarChart as default } from "./Charts" と書いた中継用のファイルを作り、そちらを lazy で読み込む書き方もあります(こちらは未実行です)。
useでPromiseを読む書き方(React 19)
React 19 の use を使うと、Promise の結果をコンポーネントの中で直接読めます。Promise が終わるまでは Suspense の fallback が表示されるので、「読み込み中かどうか」の状態を自分で持つ必要がなくなります。
public/items.json
[{"id":1,"name":"ノートPC"},{"id":2,"name":"モニター"}]
src/UseDemo.tsx
import { Suspense, use } from "react";
type Item = { id: number; name: string };
async function fetchItems(url: string): Promise<Item[]> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// Promise はコンポーネントの外(または親)で作り、同じものを渡し続ける
const itemsPromise = fetchItems("/items.json");
function ItemList({ promise }: { promise: Promise<Item[]> }) {
const items = use(promise); // 終わるまでは Suspense の fallback が出る
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
export default function UseDemo() {
return (
<Suspense fallback={<p>備品を読み込み中…</p>}>
<ItemList promise={itemsPromise} />
</Suspense>
);
}
実行結果
5ms: 備品を読み込み中…
306ms: ノートPC / モニター
use は名前こそフックに似ていますが、react.dev のリファレンスでは「if 文やループの中でも呼べる」と説明されています。一方で、try-catch の中では呼べず、エラーは ErrorBoundary で受け止めることになっています(同ページで確認)。
描画のたびにPromiseを作ると表示が進まない
use に渡す Promise は、再描画しても同じものを渡し続ける必要があります。次のようにコンポーネントの中で作ると、うまく表示されません。
import { Suspense, use } from "react";
type Item = { id: number; name: string };
function ItemList() {
// 描画のたびに新しい Promise を作っている(避けたい書き方)
const items = use(
fetch("/items.json").then((res) => res.json() as Promise<Item[]>),
);
return <p id="count">{items.length} 件</p>;
}
export default function UseBad() {
return (
<Suspense fallback={<p>備品を読み込み中…</p>}>
<ItemList />
</Suspense>
);
}
実行結果(3秒待ったあとの画面と、リクエストの回数)
useBad 3秒後: 備品を読み込み中… / items.json へのリクエスト 1192 回
fallback のまま進まず、3秒間に1,192回もリクエストが飛びました。 Promise が終わって描画し直すたびに新しい Promise が作られ、また「読み込み中」に戻るためです。react.dev にも「use に渡す Promise はキャッシュして、再描画でも同じインスタンスを使うこと」と書かれています。
| Promise を作る場所 | 結果 |
|---|---|
| モジュールの先頭(コンポーネントの外) | 表示される(上の UseDemo) |
親コンポーネントの useState(() => fetchItems(...)) |
表示される(次の ErrorDemo) |
| use を呼ぶコンポーネントの描画中 | fallback のまま進まない |
入力に応じて取り直したい場合は、TanStack Query や SWR、フレームワークのデータ取得機能など、Promise を保持してくれるしくみと組み合わせると扱いやすくなります(これらとの組み合わせは未実行です)。
ErrorBoundaryと組み合わせる
Suspense が受け持つのは「待っている間」だけです。読み込みがエラーで終わったときの表示は ErrorBoundary が受け持ちます。 ErrorBoundary は、React では今もクラスコンポーネントで書きます。
src/ErrorBoundary.tsx
import { Component, type ErrorInfo, type ReactNode } from "react";
type Props = { children: ReactNode };
type State = { error: Error | null };
// エラーを受け止めるコンポーネントは、クラスで書く
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.log("記録:", error.message, info.componentStack?.split("\n")[1]?.trim());
}
render() {
if (this.state.error) {
return (
<div role="alert">
<p id="error">表示できませんでした: {this.state.error.message}</p>
<button onClick={() => this.setState({ error: null })}>再試行</button>
</div>
);
}
return this.props.children;
}
}
src/ErrorDemo.tsx
import { Suspense, use, useState } from "react";
import { ErrorBoundary } from "./ErrorBoundary";
type Item = { id: number; name: string };
async function fetchItems(url: string): Promise<Item[]> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
function ItemList({ promise }: { promise: Promise<Item[]> }) {
const items = use(promise);
return <p id="count">{items.length} 件</p>;
}
export default function ErrorDemo() {
// 1 回目は存在しない URL、切り替えボタンで正しい URL の Promise に差し替える
const [promise, setPromise] = useState(() => fetchItems("/missing.json"));
return (
<>
<button onClick={() => setPromise(fetchItems("/items.json"))}>
正しいURLに切り替え
</button>
{/* ErrorBoundary を外側、Suspense を内側に置く */}
<ErrorBoundary>
<Suspense fallback={<p>備品を読み込み中…</p>}>
<ItemList promise={promise} />
</Suspense>
</ErrorBoundary>
</>
);
}
ファイルを返すだけのサーバー(python3 -m http.server)でビルド結果を配信して動かした結果です。
error demo: 正しいURLに切り替え表示できませんでした: HTTP 404再試行
記録: HTTP 404 at ce (http://127.0.0.1:8575/assets/index-IHu8Cz8K.js:10:1216)
切り替え直後: 正しいURLに切り替え表示できませんでした: HTTP 404再試行
再試行後: 正しいURLに切り替え2 件
ここからわかることが2つあります。
- Promise が reject されると、ErrorBoundary の表示に切り替わります。
componentDidCatchには、どのコンポーネントで起きたかの情報(componentStack)も届くので、記録に使えます。 - ErrorBoundary は、一度エラーを表示すると自分では元に戻りません。 正しい URL の Promise に差し替えただけでは表示は変わらず、「再試行」で ErrorBoundary の state を戻して初めて「2 件」が表示されました。再試行ボタンや、
keyを変えて作り直すしくみを用意しておきます。
なお、Vite の開発サーバーで同じことをすると、メッセージは次のように変わりました。
表示できませんでした: Unexpected token '<', "<!doctype "... is not valid JSON
Vite のサーバーは存在しない URL に index.html を返すため、404 ではなく JSON の解析エラーになります。API の URL を確かめるときは、ネットワークタブで応答の中身も見ると早く原因にたどり着けます。
置く順番
| 置き方 | 読み込み中 | エラー時 |
|---|---|---|
| ErrorBoundary > Suspense > 中身 | fallback が出る | ErrorBoundary の表示になる |
| Suspense > 中身(ErrorBoundary なし) | fallback が出る | 画面全体が消える(後述の再現を参照) |
基本は「ErrorBoundary を外側、Suspense を内側」です。 エラー時に画面のどこまでを差し替えるかは ErrorBoundary を置く位置で決まるので、ページ全体に1つ、壊れても周りに影響させたくない部品ごとに1つ、という置き方が扱いやすくなります。
Suspenseをネストしたときのfallbackの表示順
1つの画面に「早く届くデータ」と「時間のかかるデータ」があるとき、Suspense の置き方で見え方が変わります。プロフィール(300ms で届く)と投稿一覧(900ms で届く)で比べました。
src/NestedDemo.tsx
import { Suspense, use, useState } from "react";
function delay<T>(value: T, ms: number): Promise<T> {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
function Profile({ promise }: { promise: Promise<string> }) {
return <h2>{use(promise)}</h2>;
}
function Posts({ promise }: { promise: Promise<string[]> }) {
return <p>{use(promise).join("、")}</p>;
}
export default function NestedDemo({ nested }: { nested: boolean }) {
// プロフィールは 300ms、投稿は 900ms で届く想定
const [profile] = useState(() => delay("田中さんのページ", 300));
const [posts] = useState(() => delay(["投稿A", "投稿B"], 900));
if (!nested) {
// Suspense が 1 つ:全部そろうまで外側の fallback のまま
return (
<Suspense fallback={<p>ページを読み込み中…</p>}>
<Profile promise={profile} />
<Posts promise={posts} />
</Suspense>
);
}
// Suspense を入れ子に:先に届いた部分から表示する
return (
<Suspense fallback={<p>ページを読み込み中…</p>}>
<Profile promise={profile} />
<Suspense fallback={<p>投稿を読み込み中…</p>}>
<Posts promise={posts} />
</Suspense>
</Suspense>
);
}
本番用ビルドでの実行結果(見出しの行は読みやすい言葉に置き換えています)
== Suspense が 1 つ(nested=false)
5ms: ページを読み込み中…
909ms: 田中さんのページ / 投稿A、投稿B
== Suspense を入れ子(nested=true)
4ms: ページを読み込み中…
305ms: 田中さんのページ / 投稿を読み込み中…
904ms: 田中さんのページ / 投稿A、投稿B
| 時刻 | Suspense が 1 つ | Suspense を入れ子 |
|---|---|---|
| 0ms〜 | ページを読み込み中… | ページを読み込み中… |
| 約300ms〜 | ページを読み込み中…(そのまま) | プロフィール + 投稿を読み込み中… |
| 約900ms〜 | プロフィール + 投稿 | プロフィール + 投稿 |
flowchart LR
A["外側の fallback<br>ページを読み込み中…"] --> B["外側の中身が表示<br>内側は fallback<br>投稿を読み込み中…"]
B --> C["内側の中身も表示<br>すべてそろう"]
表示の順番は「外側の fallback → 外側の中身と内側の fallback → 内側の中身」です。 内側の fallback が先に出ることはありません。Suspense が1つだと、いちばん遅いデータがそろうまで全体が fallback のままになります。入れ子にすると、先に届いた部分から見せられます。
2つの Promise は同時に走っているので、全部そろう時刻はどちらも約900msです(300+900にはなりません)。なお、開発サーバーでは Suspense が1つの場合に 1207ms と長めに出ました。数字を比べるときは本番用ビルドで測るのがおすすめです。
区切る位置の目安は次のとおりです。
| 区切り方 | 向いている場面 |
|---|---|
| ページ全体で1つ | 中身がまとまって意味を持つ画面。ばらばらに出ると読みにくい場合 |
| 見出し・本文・サイドの単位で入れ子 | 先に骨組みを見せたい画面。遅い部分が一部に限られる場合 |
| 部品ごとに細かく | それぞれ独立したウィジェットが並ぶダッシュボード |
Failed to fetch dynamically imported moduleの再現と対処
コード分割をしたアプリを公開したあとで、次のエラーが報告されることがあります。
TypeError: Failed to fetch dynamically imported module: http://127.0.0.1:8570/assets/Report-D1j-LRou.js
再現の手順
- 分割後のアプリをビルドして配信し、ブラウザで開く(この時点の index は
Report-D1j-LRou.jsを指している) - ページを開いたまま、Report.tsx の文言を1か所変えてビルドし直す(新しいデプロイに相当)
- ブラウザで「レポート」を押す
ビルドし直すと、チャンクのファイル名(ハッシュ)が変わり、以前のファイルは dist から消えます。
after/dist/assets/Report-CTJjy84H.js 109.87 kB │ gzip: 15.49 kB
after/dist/assets/index-D7Hxo5ff.js 221.62 kB │ gzip: 69.42 kB
開いたままのページは以前のファイル名を覚えているので、存在しないファイルを取りに行きます。
net: 404 /assets/Report-D1j-LRou.js (text/html;charset=utf-8)
error: Failed to load resource: the server responded with a status of 404 (File not found)
vite:preloadError 発火: Failed to fetch dynamically imported module: http://127.0.0.1:8570/assets/Report-D1j-LRou.js
pageerror: Failed to fetch dynamically imported module: http://127.0.0.1:8570/assets/Report-D1j-LRou.js
画面: (何も表示されない)
ErrorBoundary を置いていない最小コードでは、ナビゲーションを含む画面全体が消えました。 利用者から見ると「ボタンを押したら真っ白になった」状態です。
サーバーが存在しない URL に index.html を返す設定(SPA 用のフォールバック)になっている場合は、コンソールの1行目が変わります。Nginx 1.24.0 で try_files $uri $uri/ /index.html; だけを設定して確かめた結果です。
net: 200 /assets/Report-D1j-LRou.js (text/html)
error: Failed to load module script: Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "text/html". Strict MIME type checking is enforced for module scripts per HTML spec.
pageerror: Failed to fetch dynamically imported module: http://127.0.0.1:8574/assets/Report-D1j-LRou.js
| コンソールの1行目 | サーバーの応答 | 意味 |
|---|---|---|
| Failed to load resource: ... 404 | 404 | ファイルが無い |
| Expected a JavaScript-or-Wasm module script ... MIME type of "text/html" | 200(HTML) | ファイルが無く、代わりに index.html が返っている |
どちらも原因は同じで、「開いたままのページが、デプロイで消えたチャンクを取りに行った」ことです。ネットワークが一時的に切れたときにも同じメッセージになります。
対処1:1回だけ再読み込みする lazy と ErrorBoundary
読み込めなかったときにページを再読み込みすれば、新しい index.html と新しいファイル名を取り直せます。再読み込みが繰り返されないよう、sessionStorage に目印を残して1回だけにします。
src/lazyWithReload.ts
import { lazy, type ComponentType } from "react";
const KEY = "chunk-reloaded";
// チャンクが読めなかったら 1 回だけページを再読み込みする lazy
export function lazyWithReload<T extends ComponentType<any>>(
load: () => Promise<{ default: T }>,
) {
return lazy(async () => {
try {
const module = await load();
sessionStorage.removeItem(KEY); // 読み込めたら目印を消す
return module;
} catch (error) {
if (!sessionStorage.getItem(KEY)) {
sessionStorage.setItem(KEY, "1");
window.location.reload(); // 新しい index.html を取り直す
return new Promise<{ default: T }>(() => {}); // 再読み込みまで待たせる
}
throw error; // 再読み込みしても読めないときは ErrorBoundary へ
}
});
}
src/App.tsx
import { Suspense, useState } from "react";
import { ErrorBoundary } from "./ErrorBoundary";
import Home from "./Home";
import { lazyWithReload } from "./lazyWithReload";
const Report = lazyWithReload(() => import("./Report"));
const Settings = lazyWithReload(() => import("./Settings"));
type Tab = "home" | "report" | "settings";
export default function App() {
const [tab, setTab] = useState<Tab>("home");
return (
<div>
<nav>
<button onClick={() => setTab("home")}>ホーム</button>
<button onClick={() => setTab("report")}>レポート</button>
<button onClick={() => setTab("settings")}>設定</button>
</nav>
<ErrorBoundary>
<Suspense fallback={<p id="fallback">読み込み中…</p>}>
{tab === "home" && <Home />}
{tab === "report" && <Report />}
{tab === "settings" && <Settings />}
</Suspense>
</ErrorBoundary>
</div>
);
}
同じ手順(開いたままビルドし直して「レポート」を押す)で動かした結果です。
画面: ホーム
net: 404 /assets/Report-CyK368Y4.js (text/html;charset=utf-8)
navigated: /
net: 200 /assets/index-ClOQLgjW.js (text/javascript)
もう一度クリック → 画面: レポート(新)3000 件、在庫合計 148500
net: 200 /assets/Report-RTM2XIYc.js (text/javascript)
自動で再読み込みされ、新しいチャンクで表示できました。 ただし再読み込みで画面の状態は最初に戻るので(この例では「ホーム」タブ)、開いていた場所を URL に持たせておくと、同じ画面に戻れます。
新しいファイルも無い場合(チャンクを消しただけで index は以前のまま、という状況を作って確認)は、再読み込みのあともう一度押した時点で ErrorBoundary の表示になりました。
もう一度クリック → 画面: 表示できませんでした: Failed to fetch dynamically imported module: http://127.0.0.1:8573/assets/Report-CyK368Y4.js再試行
対処2:Viteのイベントで受ける
上のログにあるとおり、Vite でビルドしたアプリでは、動的 import の読み込みがうまくいかなかったときに vite:preloadError というイベントが window に届きます。今回の確認では、次のリスナーでイベントが届き、event.payload.message に同じエラーメッセージが入っていました。
/// <reference types="vite/client" />
window.addEventListener("vite:preloadError", (event) => {
console.log("vite:preloadError 発火:", event.payload.message);
// ここで window.location.reload() を呼ぶと、アプリ全体で 1 か所の対処になる
});
TypeScript では、vite/client の型を読み込むと event.payload に型が付きます(tsc --noEmit が通ることを確認)。リスナーの中で再読み込みする形は未実行です。使うときは、対処1と同じく「1回だけ」にする目印を入れておくと、再読み込みが続くことを避けられます。
対処3:配信側で備える
| 方法 | 内容 | 今回の確認 |
|---|---|---|
| 以前のチャンクをしばらく残す | デプロイ時に assets を上書きせず、前の版のファイルも一定期間置いておく | 未実行 |
| index.html をキャッシュさせない | index.html は毎回取り直し、ハッシュ付きの assets だけを長くキャッシュする | 未実行 |
| assets のフォールバックを外す | location /assets/ { try_files $uri =404; } を足し、無いファイルには 404 を返す |
404 が返ることを確認 |
アプリ側(対処1・2)と配信側(対処3)の両方を入れておくと、デプロイのタイミングでページを開いていた人にも、途切れずに使ってもらえます。
よくある質問
Q. lazy はどのコンポーネントにも使ったほうがよいですか? A. 大きくて、最初の画面では使わないものに絞るのがおすすめです。今回の例では、109.87 kB のレポート画面を分けると最初の読み込みが約108 kB 減りましたが、0.12 kB の設定画面は分けても差がほとんどありませんでした。分けるたびに fallback の表示とリクエストが1回増える点も考えて決めます。
Q. Suspense の fallback が一瞬だけ出てちらつきます。
A. 今回の確認では、fallback は一度出ると約300ms は表示されました。それでも気になる場合は、fallback を控えめな見た目にする、区切りを大きくして切り替わる範囲を減らす、といった調整ができます。すでに表示している画面を残したまま切り替える startTransition や useTransition もあります(この記事では未実行です)。
Q. use と useEffect でのデータ取得はどう違いますか? A. useEffect では「読み込み中・完了・エラー」の状態を自分で持ちますが、use では読み込み中を Suspense、エラーを ErrorBoundary に任せられます。そのかわり、Promise を描画の外で作って同じものを渡す必要があります。
Q. ErrorBoundary を関数コンポーネントで書けますか?
A. React 19.3.0 の時点でも、エラーを受け止める側はクラスで書きます(getDerivedStateFromError と componentDidCatch を使うため)。この記事のクラスを1つ用意しておけば、使う側はすべて関数コンポーネントで書けます。
まとめ
- lazy は「動的 import を返す関数」を受け取り、Suspense の内側で使う。定義はコンポーネントの外に置く
- 分割すると最初の JS が 329.86 kB から 221.62 kB になった。分ける対象は大きくて最初に使わない画面に絞る
- 名前付きエクスポートは then で
{ default: ... }の形にそろえる。そろっていないと型エラー(TS2322)や #306 になる - use に渡す Promise は描画の外で作って同じものを渡す。描画中に作ると fallback のまま進まない
- ErrorBoundary を外側、Suspense を内側に置く。ErrorBoundary は再試行で state を戻すしくみを用意する
- Suspense を入れ子にすると、外側の中身 → 内側の中身の順に、届いた部分から表示される
- Failed to fetch dynamically imported module は、デプロイで消えたチャンクを取りに行ったときに出る。1回だけの再読み込みと ErrorBoundary、配信側の設定で備える
参考
- React 公式リファレンス「use」(https://react.dev/reference/react/use )
- React 公式「Minified React error #306」(https://react.dev/errors/306 )
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。