React 18・19で進化した Suspense は、もはや「コード分割の React.lazy 用ローディング」だけのコンポーネントではありません。use() フック、Streaming SSR、Server Components、TanStack Query や Jotai との統合により、非同期処理を宣言的に扱う中核機構へと進化しています。
本記事では、Suspense の基本から use(Promise) によるデータフェッチ、ネスト Suspense、waterfall 回避、renderToPipeableStream による Streaming SSR、TanStack Query / Jotai 連携、Error Boundary との合体パターン、AsyncBoundary 抽象化、テスト戦略、アンチパターンまで、現役 Web エンジニアがプロダクションで使い倒すための実装パターンを 30 以上のコード例で網羅します。
基本構文 / fallback 設計 /
React.lazy / ネスト / use() / データフェッチ / Error Boundary 連携 / waterfall 回避 / parallel fetching / cache() / Streaming SSR / renderToPipeableStream / useTransition / useDeferredValue / SuspenseList / RSC 連携 / AsyncBoundary / テスト / アンチパターン
- 第1章 Suspense とは何か〜「待つ」を宣言的に書く仕組み
- 第2章 fallback の設計〜スピナーから Skeleton へ
- 第3章 React.lazy によるコード分割
- 第4章 ネスト Suspense でストリーミング体験を作る
- 第5章 use() フックでデータフェッチを宣言的に書く(React 19)
- 第6章 Suspense + Error Boundary でエラー処理を分離
- 第7章 waterfall を回避する〜並列フェッチのテクニック
- 第8章 TanStack Query との統合〜useSuspenseQuery
- 第9章 Jotai の atomWithSuspense と連携
- 第10章 Streaming SSR と renderToPipeableStream
- 第11章 useTransition と useDeferredValue で UX を磨く
- 第12章 SuspenseList(実験的)と段階表示
- 第13章 RSC(React Server Components)とのシナジー
- 第14章 テスト戦略〜Suspense をどう検証するか
- 第15章 Suspense アンチパターンとベストプラクティス
- 第16章 ライブラリ拡張〜react-async-states と useSWR
- 第17章 実践:商品一覧ページを Suspense で組み直す
- 第18章 学習の次のステップ〜ITスクールで効率的に学ぶ
- よくある質問(FAQ)
- Q1. Suspense はどのバージョンの React から本格運用できますか?
- Q2. Suspense を使うと毎回 Promise を作り直してしまいます。どう避ければよいですか?
- Q3. Error Boundary は class component しか書けませんか?
- Q4. Suspense と React.lazy で動的 import するときの SSR 注意点は?
- Q5. waterfall を完全に避けるには RSC が必須ですか?
- Q6. useTransition と useDeferredValue はどう使い分けますか?
- Q7. Suspense を導入したら逆にパフォーマンスが悪化しました。原因は?
- まとめ〜Suspense は「非同期の宣言的扱い」の中心
第1章 Suspense とは何か〜「待つ」を宣言的に書く仕組み
Suspense は、子コンポーネントの「準備が整っていない状態(=Promise が pending)」を React が自動的に検知し、fallback UI を表示する仕組みです。これにより、ローディング状態の管理を子コンポーネントから親に移譲できます。
1-1. Suspense の最小構文
// 最小の Suspense: lazy ロードされたコンポーネントを包む
import { Suspense, lazy } from "react";
const HeavyComponent = lazy(() => import("./HeavyComponent"));
export default function App() {
return (
<Suspense fallback={<p>読み込み中...</p>}>
<HeavyComponent />
</Suspense>
);
}
このコードでは、HeavyComponent のチャンクが読み込まれるまで「読み込み中…」が表示されます。子側で useState+useEffect による isLoading 管理は不要です。
1-2. Suspense が解決する設計課題
| 従来の課題 | Suspense でどう解決するか |
|---|---|
各コンポーネントで isLoading state を管理 |
親の Suspense で一括 fallback 表示 |
| 三項演算子による条件分岐が散乱 | JSX が「成功パスのみ」を記述できる |
| ローディングのレイアウトシフトが激しい | fallback の粒度を Suspense で制御できる |
| エラー処理と非同期処理が混在 | Error Boundary + Suspense で責務分離 |
| SSR でデータ取得を待つと TTFB が遅い | Streaming SSR で先に HTML を送れる |
1-3. Suspense の発動条件
Suspense は次のいずれかで「サスペンド(中断)」します。
React.lazyが import 中use(Promise)が pending な Promise を渡された- サードパーティ(TanStack Query の
useSuspenseQueryなど)が内部で throw した Promise - Jotai の
atomWithSuspense/ SWR のsuspense:trueオプション
// Suspense が発動する原理(擬似コード)
// 子コンポーネントが Promise を throw すると、最寄りの Suspense が catch
function ChildComponent() {
if (!dataReady) {
throw fetchData(); // ← この Promise を Suspense が受け取る
}
return <p>{data}</p>;
}
第2章 fallback の設計〜スピナーから Skeleton へ
fallback は「待っている間のユーザー体験そのもの」です。雑なスピナーは離脱の原因になります。
2-1. Skeleton コンポーネントを作る
// components/Skeleton.tsx
type Props = {
width?: string | number;
height?: string | number;
rounded?: boolean;
};
export function Skeleton({ width = "100%", height = 16, rounded = false }: Props) {
return (
<div
style={{
width,
height,
background: "linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%)",
backgroundSize: "200% 100%",
animation: "shimmer 1.5s infinite",
borderRadius: rounded ? "50%" : 4,
}}
/>
);
}
// global.css
// @keyframes shimmer {
// 0% { background-position: 200% 0; }
// 100% { background-position: -200% 0; }
// }
2-2. レイアウトに合わせた fallback
// ユーザープロフィールカードの fallback
function ProfileSkeleton() {
return (
<div style={{ display: "flex", gap: 12, padding: 16 }}>
<Skeleton width={48} height={48} rounded />
<div style={{ flex: 1 }}>
<Skeleton width="60%" height={20} />
<div style={{ height: 8 }} />
<Skeleton width="40%" height={16} />
</div>
</div>
);
}
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userId={userId} />
</Suspense>
2-3. fallback のアンチパターン
1. ページ全体をスピナーにする(レイアウトシフト最大)
2. 「読み込み中…」テキストのみ(UX 不明確)
3. fallback 内でさらに重い処理をする(意味なし)
4. fallback が本コンテンツと全く違う高さ(CLS 悪化)
第3章 React.lazy によるコード分割
Suspense の最初のキラーアプリは コード分割(Code Splitting)。バンドルサイズが肥大化したアプリで TTI(Time to Interactive)を改善します。
3-1. 基本パターン
import { Suspense, lazy } from "react";
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));
export function Routes() {
return (
<Suspense fallback={<PageSkeleton />}>
{route === "dashboard" ? <Dashboard /> : <Settings />}
</Suspense>
);
}
3-2. named export を lazy する
// 通常 lazy() は default export 前提だが、named export も扱える
const Chart = lazy(async () => {
const mod = await import("./Chart");
return { default: mod.Chart };
});
3-3. プリロード戦略
// マウス hover でプリロード
const Modal = lazy(() => import("./Modal"));
function OpenModalButton() {
const preload = () => import("./Modal"); // 事前 fetch
return (
<button onMouseEnter={preload} onFocus={preload} onClick={open}>
開く
</button>
);
}
3-4. Vite / Webpack のチャンク名指定
// Webpack magic comment
const Editor = lazy(() =>
import(/* webpackChunkName: "editor" */ "./Editor")
);
// Vite では vite.config.ts の rollupOptions.output.manualChunks で制御
// manualChunks: { editor: ["./src/Editor.tsx"] }
第4章 ネスト Suspense でストリーミング体験を作る
Suspense はネストできます。これにより「ヘッダーは先に出して、本文は遅れて出す」体験を作れます。
4-1. ネスト Suspense の基本
export function ArticlePage({ id }: { id: string }) {
return (
<div>
<Header /> {/* 即時表示 */}
<Suspense fallback={<ArticleSkeleton />}>
<Article id={id} />
<Suspense fallback={<CommentsSkeleton />}>
<Comments articleId={id} /> {/* さらに遅延OK */}
</Suspense>
</Suspense>
</div>
);
}
4-2. 「兄弟」と「子」で挙動が違う
| 配置 | 挙動 | 用途 |
|---|---|---|
| 同じ Suspense 内の兄弟 | 全員揃うまで fallback | セットで表示したい UI |
| 別々の Suspense でラップ | 準備できた順に表示 | 段階的にコンテンツを出す |
| ネストした子 Suspense | 親 fallback の解除後に子 fallback | 階層的なローディング |
4-3. 段階的に解除されるパターン
// 3 段階でコンテンツが現れる
<Suspense fallback={<FullPageSkeleton />}>
<Hero /> {/* 1段階目 */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid /> {/* 2段階目 */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews /> {/* 3段階目 */}
</Suspense>
</Suspense>
</Suspense>
第5章 use() フックでデータフェッチを宣言的に書く(React 19)
React 19 で安定化した use() フックは、Promise や Context を「条件付きで」読めるという従来の Hook ルールを破る画期的な API です。
5-1. use(Promise) の基本
import { use, Suspense } from "react";
type User = { id: string; name: string };
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error("Failed");
return res.json();
}
// ❌ コンポーネント内で new Promise すると毎レンダで作り直される
// ✅ Promise は親や cache 経由で渡す
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // ← Suspense が pending を検知
return <h2>{user.name}</h2>;
}
export function App({ userId }: { userId: string }) {
const userPromise = fetchUser(userId); // 親で1度だけ
return (
<Suspense fallback={<p>Loading...</p>}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
5-2. use(Context) を条件付きで使う
import { use, createContext } from "react";
const ThemeContext = createContext<"light" | "dark">("light");
function Heading({ showThemed }: { showThemed: boolean }) {
if (showThemed) {
// ✅ if の中で use() できる(useContext は不可)
const theme = use(ThemeContext);
return <h1 className={theme}>Hello</h1>;
}
return <h1>Hello</h1>;
}
5-3. cache() で Promise をメモ化(React Server Components)
import { cache } from "react";
// React の cache() でリクエスト単位にメモ化
export const getUser = cache(async (id: string) => {
const res = await fetch(`/api/users/${id}`, { next: { revalidate: 60 } });
return res.json();
});
// 同じ id なら何度呼んでも 1 回しか fetch されない
export default async function Page({ params }: { params: { id: string } }) {
const user = await getUser(params.id);
return <UserCard user={user} />;
}
5-4. クライアントで Promise をキャッシュするヘルパー
// クライアント用の簡易 cache
const promiseCache = new Map<string, Promise<unknown>>();
export function cachedFetch<T>(key: string, factory: () => Promise<T>): Promise<T> {
if (!promiseCache.has(key)) {
promiseCache.set(key, factory());
}
return promiseCache.get(key) as Promise<T>;
}
// 使い方
const userPromise = cachedFetch(`user:${id}`, () => fetchUser(id));
第6章 Suspense + Error Boundary でエラー処理を分離
Suspense は「pending」だけを扱います。「失敗」はError Boundary の責務です。両者を組み合わせるのが本番運用の鉄則です。
6-1. シンプルな Error Boundary
import { Component, ReactNode } from "react";
type Props = { fallback: (error: Error, reset: () => void) => ReactNode; children: ReactNode };
type State = { error: Error | null };
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error) { return { error }; }
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("[ErrorBoundary]", error, info);
}
reset = () => this.setState({ error: null });
render() {
if (this.state.error) return this.props.fallback(this.state.error, this.reset);
return this.props.children;
}
}
6-2. react-error-boundary を使う(推奨)
// npm i react-error-boundary
import { ErrorBoundary } from "react-error-boundary";
function ErrorFallback({ error, resetErrorBoundary }: {
error: Error;
resetErrorBoundary: () => void;
}) {
return (
<div role="alert">
<p>エラーが発生しました: {error.message}</p>
<button onClick={resetErrorBoundary}>再試行</button>
</div>
);
}
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => refetch()}>
<Suspense fallback={<Skeleton />}>
<UserProfile userId={id} />
</Suspense>
</ErrorBoundary>
6-3. AsyncBoundary パターン(Suspense + Error 統合)
// components/AsyncBoundary.tsx
import { ReactNode, Suspense } from "react";
import { ErrorBoundary, FallbackProps } from "react-error-boundary";
type Props = {
pendingFallback: ReactNode;
rejectedFallback: (props: FallbackProps) => ReactNode;
onReset?: () => void;
children: ReactNode;
};
export function AsyncBoundary({
pendingFallback,
rejectedFallback,
onReset,
children,
}: Props) {
return (
<ErrorBoundary FallbackComponent={rejectedFallback as never} onReset={onReset}>
<Suspense fallback={pendingFallback}>{children}</Suspense>
</ErrorBoundary>
);
}
// 使い方
<AsyncBoundary
pendingFallback={<Skeleton />}
rejectedFallback={({ error, resetErrorBoundary }) => (
<ErrorView error={error} onRetry={resetErrorBoundary} />
)}
>
<UserProfile userId={id} />
</AsyncBoundary>
第7章 waterfall を回避する〜並列フェッチのテクニック
Suspense を使うと簡単に陥るアンチパターンが waterfall(直列フェッチ)。「Suspense ボーダリーが順に解除される」=「順に fetch している」ことを意味します。
7-1. ❌ waterfall パターン
// ❌ User -> Posts の順に直列で fetch されてしまう
function Page({ userId }: { userId: string }) {
const user = use(fetchUser(userId)); // fetch 1
const posts = use(fetchPosts(user.postIds)); // fetch 2(user 完了後に開始)
return <PostList posts={posts} />;
}
7-2. ✅ parallel パターン
// ✅ Promise を親で同時に開始してから渡す
export function Page({ userId }: { userId: string }) {
const userPromise = fetchUser(userId);
const postsPromise = fetchPostsByUser(userId); // userId だけで取れる場合
return (
<Suspense fallback={<Skeleton />}>
<UserHeader userPromise={userPromise} />
<PostList postsPromise={postsPromise} />
</Suspense>
);
}
7-3. Promise.all で集約
async function loadDashboard(userId: string) {
const [user, stats, notifications] = await Promise.all([
fetchUser(userId),
fetchStats(userId),
fetchNotifications(userId),
]);
return { user, stats, notifications };
}
function Dashboard({ data }: { data: ReturnType<typeof loadDashboard> }) {
const { user, stats, notifications } = use(data);
return (/* ... */);
}
7-4. waterfall 検出のチェックリスト
- 子コンポーネントが「親の data の結果」を引数に
fetchしていないか - Suspense ボーダリーの解除順を Network タブで確認
- 並列に出来る fetch は親で
Promise.allしていないか - 初期表示で必要なデータは loader / RSC で取れているか
第8章 TanStack Query との統合〜useSuspenseQuery
本番アプリでは use() を生で使うより、TanStack Query の useSuspenseQuery が運用しやすいです。
8-1. useSuspenseQuery の基本
// npm i @tanstack/react-query
import { useSuspenseQuery, QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
function User({ userId }: { userId: string }) {
// ❗ data は必ず存在(undefined ではない)
const { data: user } = useSuspenseQuery({
queryKey: ["user", userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
});
return <h2>{user.name}</h2>;
}
export function App() {
return (
<QueryClientProvider client={queryClient}>
<ErrorBoundary fallback={<p>Error</p>}>
<Suspense fallback={<Skeleton />}>
<User userId="42" />
</Suspense>
</ErrorBoundary>
</QueryClientProvider>
);
}
8-2. useSuspenseQueries で並列
import { useSuspenseQueries } from "@tanstack/react-query";
function MultiPanel({ ids }: { ids: string[] }) {
const results = useSuspenseQueries({
queries: ids.map((id) => ({
queryKey: ["item", id],
queryFn: () => fetch(`/api/items/${id}`).then(r => r.json()),
})),
});
// results は data 確実
return (
<ul>
{results.map((r, i) => <li key={ids[i]}>{r.data.title}</li>)}
</ul>
);
}
8-3. prefetchQuery で初期表示を高速化
// Route loader / Server Component で prefetch
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
export async function loader({ params }: { params: { id: string } }) {
const qc = new QueryClient();
await qc.prefetchQuery({
queryKey: ["user", params.id],
queryFn: () => fetchUser(params.id),
});
return { dehydratedState: dehydrate(qc) };
}
function Page({ dehydratedState }: { dehydratedState: unknown }) {
return (
<HydrationBoundary state={dehydratedState}>
<Suspense fallback={<Skeleton />}>
<User userId={id} />
</Suspense>
</HydrationBoundary>
);
}
第9章 Jotai の atomWithSuspense と連携
Jotai は async な atom を Suspense に統合できます。状態管理ライブラリとしてシンプルに非同期を扱いたい場合に有効です。
9-1. async atom
// npm i jotai
import { atom, useAtomValue } from "jotai";
const userIdAtom = atom("42");
// async atom は自動的に Suspense と連動する
const userAtom = atom(async (get) => {
const id = get(userIdAtom);
const res = await fetch(`/api/users/${id}`);
return res.json() as Promise<{ name: string }>;
});
function User() {
const user = useAtomValue(userAtom); // Suspense 発動
return <p>{user.name}</p>;
}
9-2. loadable で Suspense を切る
import { loadable } from "jotai/utils";
const userLoadableAtom = loadable(userAtom);
function User() {
const state = useAtomValue(userLoadableAtom);
if (state.state === "loading") return <Skeleton />;
if (state.state === "hasError") return <p>Error</p>;
return <p>{state.data.name}</p>;
}
第10章 Streaming SSR と renderToPipeableStream
React 18 で導入された Streaming SSR は、Suspense と同期して動きます。データ取得を待たずに HTML の上から順にチャンクで送れます。
10-1. Node.js での Streaming SSR
// server.tsx
import { renderToPipeableStream } from "react-dom/server";
import express from "express";
import App from "./App";
const app = express();
app.get("/", (req, res) => {
let didError = false;
const { pipe, abort } = renderToPipeableStream(<App />, {
bootstrapScripts: ["/client.js"],
onShellReady() {
res.statusCode = didError ? 500 : 200;
res.setHeader("Content-Type", "text/html");
pipe(res);
},
onShellError() {
res.statusCode = 500;
res.send("<h1>Server Error</h1>");
},
onError(err) {
didError = true;
console.error(err);
},
});
setTimeout(() => abort(), 10_000); // タイムアウト保護
});
10-2. Edge ランタイム向け renderToReadableStream
// edge worker (Cloudflare Workers / Vercel Edge)
import { renderToReadableStream } from "react-dom/server.edge";
export default {
async fetch(request: Request) {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ["/client.js"],
});
return new Response(stream, { headers: { "Content-Type": "text/html" } });
},
};
10-3. Suspense との連動イメージ
| 時刻 | サーバー | クライアント |
|---|---|---|
| 0ms | shell HTML(ヘッダー等)送信 | ヘッダー描画開始 |
| 50ms | JS bundles プリロード | JS パース開始 |
| 300ms | Article データ完了 → 該当チャンク送信 | 本文 fallback と入れ替わり |
| 800ms | Comments データ完了 → チャンク送信 | コメントが入れ替わる |
第11章 useTransition と useDeferredValue で UX を磨く
Suspense と相性が良い 2 つのフックを押さえます。遷移を「緊急ではない」とマークすることで、入力反応性を保ったままサスペンドを許可できます。
11-1. useTransition で「ちらつき」を防ぐ
import { useState, useTransition, Suspense } from "react";
function Search() {
const [query, setQuery] = useState("");
const [isPending, startTransition] = useTransition();
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value;
// 入力即時反映、検索は非緊急
startTransition(() => setQuery(next));
};
return (
<>
<input onChange={onChange} />
{isPending && <Spinner inline />}
<Suspense fallback={<ResultsSkeleton />}>
<SearchResults query={query} />
</Suspense>
</>
);
}
11-2. useDeferredValue で「重い再描画」を後回し
import { useDeferredValue, useState } from "react";
function FilterableList({ items }: { items: Item[] }) {
const [keyword, setKeyword] = useState("");
const deferred = useDeferredValue(keyword);
const isStale = keyword !== deferred;
return (
<>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<ul style={{ opacity: isStale ? 0.5 : 1 }}>
{items.filter(i => i.name.includes(deferred)).map(i =>
<li key={i.id}>{i.name}</li>
)}
</ul>
</>
);
}
11-3. transition と Suspense の相互作用
// startTransition の中で setState すると、サスペンド中も前回 UI を保持
const [tab, setTab] = useState<"users" | "posts">("users");
const [isPending, startTransition] = useTransition();
return (
<>
<button onClick={() => startTransition(() => setTab("posts"))} disabled={isPending}>
Posts
</button>
<Suspense fallback={<Skeleton />}>
{tab === "users" ? <Users /> : <Posts />}
</Suspense>
</>
);
第12章 SuspenseList(実験的)と段階表示
複数の Suspense を「どの順番で出すか」を制御するのが SuspenseList(React 18 では experimental 名前空間)。本番採用には注意が必要ですが、概念は理解しておきたいところです。
12-1. revealOrder と tail
// React 18 では unstable_SuspenseList
import { unstable_SuspenseList as SuspenseList, Suspense } from "react";
<SuspenseList revealOrder="forwards" tail="collapsed">
<Suspense fallback={<S />}><Card1 /></Suspense>
<Suspense fallback={<S />}><Card2 /></Suspense>
<Suspense fallback={<S />}><Card3 /></Suspense>
</SuspenseList>
| prop | 値 | 挙動 |
|---|---|---|
| revealOrder | forwards / backwards / together | 表示順を制御 |
| tail | collapsed / hidden / (省略) | 未解決の fallback の出し方 |
第13章 RSC(React Server Components)とのシナジー
RSC では サーバー側で async コンポーネントを書けるため、Suspense は「サーバーコンポーネントの境界」としてさらに重要になります。
13-1. async サーバーコンポーネント
// app/users/[id]/page.tsx (Next.js App Router)
import { Suspense } from "react";
import Profile from "./Profile";
import Posts from "./Posts";
export default function Page({ params }: { params: { id: string } }) {
return (
<>
<Suspense fallback={<ProfileSkeleton />}>
<Profile id={params.id} />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<Posts userId={params.id} />
</Suspense>
</>
);
}
// Profile.tsx (Server Component)
export default async function Profile({ id }: { id: string }) {
const user = await fetch(`https://api.example.com/users/${id}`).then(r => r.json());
return <h2>{user.name}</h2>;
}
13-2. クライアント境界の引き方
// components/InteractiveChart.tsx
"use client";
import { useState } from "react";
export function InteractiveChart({ data }: { data: number[] }) {
const [hover, setHover] = useState(null);
// インタラクティブ要素のみクライアント
return /* ... */;
}
// app/dashboard/page.tsx (Server Component)
import { InteractiveChart } from "@/components/InteractiveChart";
export default async function Dashboard() {
const data = await fetchSeries(); // サーバーで fetch
return (
<Suspense fallback={<ChartSkeleton />}>
<InteractiveChart data={data} />
</Suspense>
);
}
第14章 テスト戦略〜Suspense をどう検証するか
Suspense を含むコンポーネントのテストは、fallback を待ってから検証するのが基本です。
14-1. Vitest + React Testing Library
// UserProfile.test.tsx
import { render, screen } from "@testing-library/react";
import { Suspense } from "react";
import { describe, it, expect, vi } from "vitest";
import UserProfile from "./UserProfile";
vi.mock("./api", () => ({
fetchUser: vi.fn().mockResolvedValue({ id: "1", name: "Alice" }),
}));
describe("UserProfile", () => {
it("fallback 後にユーザー名が表示される", async () => {
render(
<Suspense fallback={<p>loading</p>}>
<UserProfile userId="1" />
</Suspense>
);
expect(screen.getByText("loading")).toBeInTheDocument();
expect(await screen.findByText("Alice")).toBeInTheDocument();
});
});
14-2. MSW でネットワーク層をモック
// test/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users/:id", () => HttpResponse.json({ id: "1", name: "Alice" })),
];
// test/setup.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
14-3. TanStack Query テスト用 wrapper
function createWrapper() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
}
render(
<Suspense fallback={<p>L</p>}>
<User userId="1" />
</Suspense>,
{ wrapper: createWrapper() }
);
第15章 Suspense アンチパターンとベストプラクティス
15-1. やってはいけない 7 つのパターン
| アンチパターン | なぜダメか | 正しい書き方 |
|---|---|---|
| コンポーネント内で Promise を作る | 毎レンダで新 Promise → 無限ループ | 親 / cache に Promise を保持 |
Suspense なしで use(Promise) |
最寄り祖先まで遡る予期せぬ fallback | 必ず直近に Suspense を置く |
| Error Boundary を付けない | rejected Promise が拾えず白画面 | AsyncBoundary で常にセット |
| waterfall を放置 | TTI が遅い | parallel / RSC でまとめる |
| fallback がページ全体スピナー | レイアウトシフトと体感悪化 | Skeleton をエリア単位で |
Suspense の中で useEffect + fetch |
Suspense と CSR fetch の二重管理 | useSuspenseQuery 等に寄せる |
| SuspenseList を本番で多用 | experimental で挙動不安定 | 必要最小限・代替案検討 |
15-2. プロダクション運用チェックリスト
- fallback はエリア単位の Skeleton になっているか
- 各 Suspense に対応する Error Boundary があるか
- waterfall は Network タブで検証したか
useTransitionでちらつきを抑えているか- サーバーで取得できるデータは RSC / loader 経由か
- テストで fallback → 完了の両状態を検証しているか
- タイムアウト保護(
abort())を入れたか
15-3. パフォーマンス計測スニペット
// React Profiler API でサスペンド時間を計測
import { Profiler } from "react";
<Profiler id="UserProfile" onRender={(id, phase, actual, base, start, commit) => {
console.log({ id, phase, actual, base, start, commit });
}}>
<Suspense fallback={<S />}><UserProfile /></Suspense>
</Profiler>
第16章 ライブラリ拡張〜react-async-states と useSWR
16-1. useSWR の suspense オプション
// npm i swr
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then(r => r.json());
function User({ id }: { id: string }) {
const { data } = useSWR(`/api/users/${id}`, fetcher, { suspense: true });
return <p>{data.name}</p>; // ← data 必ず存在
}
16-2. 自作 createResource パターン
// 「読めるリソース」を作る薄いラッパー
type Status = "pending" | "success" | "error";
export function createResource<T>(promise: Promise<T>) {
let status: Status = "pending";
let result: T;
let error: unknown;
const suspender = promise.then(
(r) => { status = "success"; result = r; },
(e) => { status = "error"; error = e; }
);
return {
read(): T {
if (status === "pending") throw suspender;
if (status === "error") throw error;
return result;
},
};
}
// 使い方
const userResource = createResource(fetchUser(id));
function User() {
const user = userResource.read(); // Suspense と統合
return <p>{user.name}</p>;
}
第17章 実践:商品一覧ページを Suspense で組み直す
最後に、ここまでの知識を結集して実プロダクションを想定した商品一覧ページを組みます。
17-1. ページ全体の構造
// app/products/page.tsx
import { Suspense } from "react";
import { AsyncBoundary } from "@/components/AsyncBoundary";
import { CategoryNav } from "./CategoryNav";
import { ProductGrid } from "./ProductGrid";
import { Recommendations } from "./Recommendations";
export default function ProductsPage() {
return (
<main>
<h1>商品一覧</h1>
<AsyncBoundary
pendingFallback={<CategoryNavSkeleton />}
rejectedFallback={({ error, resetErrorBoundary }) => (
<ErrorBanner error={error} onRetry={resetErrorBoundary} />
)}
>
<CategoryNav />
</AsyncBoundary>
<AsyncBoundary
pendingFallback={<ProductGridSkeleton count={12} />}
rejectedFallback={({ error, resetErrorBoundary }) => (
<ErrorBanner error={error} onRetry={resetErrorBoundary} />
)}
>
<ProductGrid />
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</AsyncBoundary>
</main>
);
}
17-2. データ層のキャッシュと並列化
// app/products/queries.ts
import { cache } from "react";
export const getCategories = cache(async () => {
const r = await fetch("https://api/categories", { next: { revalidate: 3600 } });
return r.json();
});
export const getProducts = cache(async (cat?: string) => {
const url = cat ? `https://api/products?category=${cat}` : "https://api/products";
const r = await fetch(url, { next: { revalidate: 60 } });
return r.json();
});
export const getRecommendations = cache(async (userId: string) => {
const r = await fetch(`https://api/recs/${userId}`, { next: { revalidate: 120 } });
return r.json();
});
17-3. インタラクティブな絞り込み(クライアント)
"use client";
import { useState, useTransition, Suspense } from "react";
export function CategoryFilter({ initial }: { initial: string }) {
const [cat, setCat] = useState(initial);
const [isPending, startTransition] = useTransition();
const onChange = (next: string) =>
startTransition(() => setCat(next));
return (
<>
<CategoryTabs current={cat} onChange={onChange} pending={isPending} />
<Suspense fallback={<ProductGridSkeleton count={12} />}>
<ProductGrid category={cat} />
</Suspense>
</>
);
}
第18章 学習の次のステップ〜ITスクールで効率的に学ぶ
本記事のような Suspense・Streaming SSR・RSC は、動くコード+設計の意図+プロダクション運用の三点を押さえないと現場で詰まりがちです。独学が苦しい方は、現役エンジニアのメンタリングがあるスクールでの学習が近道です。
- テックアカデミー: 完全オンライン、React・Next.js コースで Suspense / RSC / App Router を扱う
- 侍エンジニア: マンツーマンで現役メンター。Suspense+TanStack Query などの実践課題に対応可能
- DMM WEBCAMP: 転職保証付き、モダンフロントに踏み込んだカリキュラム
- レバテックカレッジ: 大学生・若手向け、現場の React 案件に直結
よくある質問(FAQ)
Q1. Suspense はどのバージョンの React から本格運用できますか?
React 18 で安定化し、Streaming SSR・useTransition と一緒に「使ってよい」状態になりました。React 19 では use() が安定化し、データフェッチ用途も標準的に書けるようになっています。
Q2. Suspense を使うと毎回 Promise を作り直してしまいます。どう避ければよいですか?
「Promise を作る場所」と「Promise を読む場所」を分離します。サーバーは cache()、クライアントは TanStack Query / SWR / 自作 cache 経由で Promise を再利用しましょう。本文 5-3 と 5-4 のコードを参照してください。
Q3. Error Boundary は class component しか書けませんか?
React 標準としては class が必要ですが、react-error-boundary ライブラリを使えば実質的に関数コンポーネントとして扱えます。本文 6-2 を参照。
Q4. Suspense と React.lazy で動的 import するときの SSR 注意点は?
Next.js App Router など RSC 環境では、React.lazy はクライアント境界("use client")内で使うのが安全です。サーバーコンポーネントでは dynamic(() => import(...)) ヘルパーを使うか、そもそも RSC では分割が自動なので不要な場合があります。
Q5. waterfall を完全に避けるには RSC が必須ですか?
必須ではありません。クライアント側でも親で Promise.all したり、TanStack Query の useSuspenseQueries を使えば回避できます。ただし RSC を使えるなら、サーバー側で並列フェッチした方が転送量も TTFB も有利です。
Q6. useTransition と useDeferredValue はどう使い分けますか?
useTransition は「自分が setState する側」のとき、useDeferredValue は「props で受け取った値の反映を遅らせたい」とき、と覚えると分かりやすいです。両方とも「緊急ではない更新」をマークする仕組みで、Suspense と合わせるとちらつきが大幅に減ります。
Q7. Suspense を導入したら逆にパフォーマンスが悪化しました。原因は?
最も多いのは waterfall と fallback の粒度ミスです。本文 7 章の検出チェックリストで Network タブを確認し、Skeleton を親より小さく描画するように設計し直してください。React DevTools の Profiler、Lighthouse、本文 15-3 のコードで actualDuration を計測するのも有効です。
まとめ〜Suspense は「非同期の宣言的扱い」の中心
React の Suspense は、コード分割の補助具から始まり、いまや クライアントとサーバーをまたぐ非同期処理の宣言的な中核機構になりました。本記事のポイントを再掲します。
- fallback は Skeleton をエリア単位で。ページ全体スピナーは避ける
use(Promise)は親 / cache 経由で Promise を渡す- Suspense は Error Boundary と必ずセットで(AsyncBoundary 推奨)
- waterfall は親で
Promise.all/ parallel フェッチで回避 - Streaming SSR は
renderToPipeableStreamで 1 行有効化 useTransitionとuseDeferredValueでちらつきを抑える- RSC を使えるなら、サーバー境界の引き方を Suspense で設計
- テストは fallback → 解決の両状態を必ず確認
本記事のコードを土台に、ぜひあなたのアプリの非同期処理を Suspense ベースで設計し直してみてください。「isLoading 状態を散らかさない、宣言的で保守しやすいコード」が手に入ります。
- Reactパフォーマンス最適化完全ガイド(React Compiler・memo化・コード分割)
- TanStack Query (React Query) 完全実践ガイド〜サーバー状態管理・キャッシュ戦略
- Jotai 完全実践ガイド〜原子的状態管理・atomFamily・loadable
- React Testing Library 完全実践ガイド〜Vitest・MSW・カスタムフック
- カスタムフック作り方完全ガイド〜命名規則・設計パターン

コメント