React Suspense完全ガイド〜lazy・use・データフェッチ・Streaming SSR【2026年版】〜

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 以上のコード例で網羅します。

この記事で扱う Suspense の全貌
基本構文 / fallback 設計 / React.lazy / ネスト / use() / データフェッチ / Error Boundary 連携 / waterfall 回避 / parallel fetching / cache() / Streaming SSR / renderToPipeableStream / useTransition / useDeferredValue / SuspenseList / RSC 連携 / AsyncBoundary / テスト / アンチパターン
  1. 第1章 Suspense とは何か〜「待つ」を宣言的に書く仕組み
    1. 1-1. Suspense の最小構文
    2. 1-2. Suspense が解決する設計課題
    3. 1-3. Suspense の発動条件
  2. 第2章 fallback の設計〜スピナーから Skeleton へ
    1. 2-1. Skeleton コンポーネントを作る
    2. 2-2. レイアウトに合わせた fallback
    3. 2-3. fallback のアンチパターン
  3. 第3章 React.lazy によるコード分割
    1. 3-1. 基本パターン
    2. 3-2. named export を lazy する
    3. 3-3. プリロード戦略
    4. 3-4. Vite / Webpack のチャンク名指定
  4. 第4章 ネスト Suspense でストリーミング体験を作る
    1. 4-1. ネスト Suspense の基本
    2. 4-2. 「兄弟」と「子」で挙動が違う
    3. 4-3. 段階的に解除されるパターン
  5. 第5章 use() フックでデータフェッチを宣言的に書く(React 19)
    1. 5-1. use(Promise) の基本
    2. 5-2. use(Context) を条件付きで使う
    3. 5-3. cache() で Promise をメモ化(React Server Components)
    4. 5-4. クライアントで Promise をキャッシュするヘルパー
  6. 第6章 Suspense + Error Boundary でエラー処理を分離
    1. 6-1. シンプルな Error Boundary
    2. 6-2. react-error-boundary を使う(推奨)
    3. 6-3. AsyncBoundary パターン(Suspense + Error 統合)
  7. 第7章 waterfall を回避する〜並列フェッチのテクニック
    1. 7-1. ❌ waterfall パターン
    2. 7-2. ✅ parallel パターン
    3. 7-3. Promise.all で集約
    4. 7-4. waterfall 検出のチェックリスト
  8. 第8章 TanStack Query との統合〜useSuspenseQuery
    1. 8-1. useSuspenseQuery の基本
    2. 8-2. useSuspenseQueries で並列
    3. 8-3. prefetchQuery で初期表示を高速化
  9. 第9章 Jotai の atomWithSuspense と連携
    1. 9-1. async atom
    2. 9-2. loadable で Suspense を切る
  10. 第10章 Streaming SSR と renderToPipeableStream
    1. 10-1. Node.js での Streaming SSR
    2. 10-2. Edge ランタイム向け renderToReadableStream
    3. 10-3. Suspense との連動イメージ
  11. 第11章 useTransition と useDeferredValue で UX を磨く
    1. 11-1. useTransition で「ちらつき」を防ぐ
    2. 11-2. useDeferredValue で「重い再描画」を後回し
    3. 11-3. transition と Suspense の相互作用
  12. 第12章 SuspenseList(実験的)と段階表示
    1. 12-1. revealOrder と tail
  13. 第13章 RSC(React Server Components)とのシナジー
    1. 13-1. async サーバーコンポーネント
    2. 13-2. クライアント境界の引き方
  14. 第14章 テスト戦略〜Suspense をどう検証するか
    1. 14-1. Vitest + React Testing Library
    2. 14-2. MSW でネットワーク層をモック
    3. 14-3. TanStack Query テスト用 wrapper
  15. 第15章 Suspense アンチパターンとベストプラクティス
    1. 15-1. やってはいけない 7 つのパターン
    2. 15-2. プロダクション運用チェックリスト
    3. 15-3. パフォーマンス計測スニペット
  16. 第16章 ライブラリ拡張〜react-async-states と useSWR
    1. 16-1. useSWR の suspense オプション
    2. 16-2. 自作 createResource パターン
  17. 第17章 実践:商品一覧ページを Suspense で組み直す
    1. 17-1. ページ全体の構造
    2. 17-2. データ層のキャッシュと並列化
    3. 17-3. インタラクティブな絞り込み(クライアント)
  18. 第18章 学習の次のステップ〜ITスクールで効率的に学ぶ
  19. よくある質問(FAQ)
    1. Q1. Suspense はどのバージョンの React から本格運用できますか?
    2. Q2. Suspense を使うと毎回 Promise を作り直してしまいます。どう避ければよいですか?
    3. Q3. Error Boundary は class component しか書けませんか?
    4. Q4. Suspense と React.lazy で動的 import するときの SSR 注意点は?
    5. Q5. waterfall を完全に避けるには RSC が必須ですか?
    6. Q6. useTransition と useDeferredValue はどう使い分けますか?
    7. Q7. Suspense を導入したら逆にパフォーマンスが悪化しました。原因は?
  20. まとめ〜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 のアンチパターン

避けたい 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 検出のチェックリスト

  1. 子コンポーネントが「親の data の結果」を引数に fetch していないか
  2. Suspense ボーダリーの解除順を Network タブで確認
  3. 並列に出来る fetch は親で Promise.all していないか
  4. 初期表示で必要なデータは 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 モダン環境を学べる主要スクール

  • テックアカデミー: 完全オンライン、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 を導入したら逆にパフォーマンスが悪化しました。原因は?

最も多いのは waterfallfallback の粒度ミスです。本文 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 行有効化
  • useTransitionuseDeferredValue でちらつきを抑える
  • RSC を使えるなら、サーバー境界の引き方を Suspense で設計
  • テストは fallback → 解決の両状態を必ず確認

本記事のコードを土台に、ぜひあなたのアプリの非同期処理を Suspense ベースで設計し直してみてください。「isLoading 状態を散らかさない、宣言的で保守しやすいコード」が手に入ります。

関連記事

  • Reactパフォーマンス最適化完全ガイド(React Compiler・memo化・コード分割)
  • TanStack Query (React Query) 完全実践ガイド〜サーバー状態管理・キャッシュ戦略
  • Jotai 完全実践ガイド〜原子的状態管理・atomFamily・loadable
  • React Testing Library 完全実践ガイド〜Vitest・MSW・カスタムフック
  • カスタムフック作り方完全ガイド〜命名規則・設計パターン

コメント

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