ストリーミングHTML完全ガイド — Suspenseとプログレッシブレンダリング
ストリーミングHTMLとは
従来のSSRでは、サーバーが全てのHTMLを生成してからクライアントに送信していました。ストリーミングSSRでは、準備ができた部分から順次送信することで、初期表示を大幅に高速化できます。
従来のSSR
[サーバー] データ取得(3秒) → HTML生成 → 送信
[クライアント] 待機(3秒) → 受信 → 表示
ストリーミングSSR
[サーバー] 初期HTML送信(即座) → データ取得完了時に追加HTML送信
[クライアント] 初期HTML表示(即座) → 追加部分を動的挿入
React 19のストリーミング機能
基本的なSuspense境界
import { Suspense } from 'react';
export default function Page() {
return (
<div>
<h1>ダッシュボード</h1>
{/* 即座に表示 */}
<QuickStats />
{/* 非同期データ読み込み中はフォールバック表示 */}
<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
</div>
);
}
非同期コンポーネント
async function SalesChart() {
// サーバー側で非同期データ取得
const data = await fetch('https://api.example.com/sales', {
next: { revalidate: 3600 }
}).then(r => r.json());
return (
<div>
<h2>売上チャート</h2>
<Chart data={data} />
</div>
);
}
async function RecentOrders() {
const orders = await db.query('SELECT * FROM orders ORDER BY created_at DESC LIMIT 10');
return (
<table>
<thead>
<tr>
<th>注文ID</th>
<th>顧客名</th>
<th>金額</th>
</tr>
</thead>
<tbody>
{orders.map(order => (
<tr key={order.id}>
<td>{order.id}</td>
<td>{order.customerName}</td>
<td>{order.amount}</td>
</tr>
))}
</tbody>
</table>
);
}
Suspense境界設計パターン
パターン1: ネストされたSuspense
export default function BlogPost({ id }: { id: string }) {
return (
<article>
{/* 記事本文は優先度高 */}
<Suspense fallback={<PostSkeleton />}>
<PostContent id={id} />
{/* コメントは優先度低 */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments postId={id} />
</Suspense>
</Suspense>
</article>
);
}
パターン2: 並列ストリーミング
export default function Dashboard() {
return (
<div className="grid grid-cols-2 gap-4">
<Suspense fallback={<Skeleton />}>
<MetricCard metric="revenue" />
</Suspense>
<Suspense fallback={<Skeleton />}>
<MetricCard metric="users" />
</Suspense>
<Suspense fallback={<Skeleton />}>
<MetricCard metric="orders" />
</Suspense>
<Suspense fallback={<Skeleton />}>
<MetricCard metric="pageviews" />
</Suspense>
</div>
);
}
各カードは独立してストリーミングされ、準備ができた順に表示されます。
パターン3: ウォーターフォール回避
// ❌ 悪い例: シーケンシャル読み込み
async function BadExample() {
const user = await fetchUser();
const posts = await fetchPosts(user.id); // userの完了を待つ
return <div>{/* ... */}</div>;
}
// ✅ 良い例: 並列化
async function GoodExample() {
const userPromise = fetchUser();
const postsPromise = fetchPosts(); // 並列実行
const [user, posts] = await Promise.all([userPromise, postsPromise]);
return <div>{/* ... */}</div>;
}
// ✅ より良い例: Suspense境界で分離
export default function Profile() {
return (
<>
<Suspense fallback={<UserSkeleton />}>
<UserInfo />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<UserPosts />
</Suspense>
</>
);
}
Out-of-Order Streaming
React 19では、Suspense境界の完了順に関係なく、HTML内の正しい位置に挿入されます。
実装例
export default function Page() {
return (
<main>
{/* 1. 即座に表示 */}
<Header />
{/* 2. 5秒かかるクエリ */}
<Suspense fallback={<div>読み込み中...</div>}>
<SlowComponent />
</Suspense>
{/* 3. 1秒で完了 */}
<Suspense fallback={<div>読み込み中...</div>}>
<FastComponent />
</Suspense>
{/* 4. 即座に表示 */}
<Footer />
</main>
);
}
レンダリング順序:
- Header (0秒)
- Footer (0秒)
- FastComponent (1秒) ← 先に完了してもSlowComponentの後ろに挿入されない
- SlowComponent (5秒)
実際のHTML出力
<!-- 初期レスポンス -->
<main>
<header>...</header>
<div>読み込み中...</div> <!-- SlowComponent fallback -->
<div>読み込み中...</div> <!-- FastComponent fallback -->
<footer>...</footer>
</main>
<!-- 1秒後のストリーム -->
<template id="B:1">
<div>FastComponentの内容</div>
</template>
<script>
// 正しい位置に挿入
document.getElementById('suspense-2').replaceWith(
document.getElementById('B:1').content
);
</script>
<!-- 5秒後のストリーム -->
<template id="B:0">
<div>SlowComponentの内容</div>
</template>
<script>
document.getElementById('suspense-1').replaceWith(
document.getElementById('B:0').content
);
</script>
プログレッシブハイドレーション
Selective Hydration
React 19では、ストリーミングされたコンポーネントを選択的にハイドレーションします。
'use client';
import { useState } from 'react';
export function InteractiveChart({ data }: { data: ChartData }) {
const [filter, setFilter] = useState('all');
return (
<div>
<select value={filter} onChange={e => setFilter(e.target.value)}>
<option value="all">全期間</option>
<option value="month">今月</option>
<option value="week">今週</option>
</select>
<Chart data={data} filter={filter} />
</div>
);
}
このコンポーネントがストリーミングで届いた後、ユーザーがクリックする前にハイドレーションが完了していなくても、クリックイベントは記録され、ハイドレーション完了後に実行されます。
優先度付きハイドレーション
import { use } from 'react';
export default function Page() {
return (
<>
{/* 高優先度: ユーザーがすぐに操作する可能性が高い */}
<Suspense fallback={<div>...</div>}>
<SearchBar />
</Suspense>
{/* 低優先度: スクロールしないと見えない */}
<Suspense fallback={<div>...</div>}>
<Footer />
</Suspense>
</>
);
}
React は画面に見えているコンポーネントを優先的にハイドレーションします。
Next.js App Routerでの実践
app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body>
{/* 静的なヘッダーは即座に表示 */}
<header>
<nav>
<a href="/">ホーム</a>
<a href="/about">概要</a>
</nav>
</header>
{children}
</body>
</html>
);
}
app/dashboard/page.tsx
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div>
<h1>ダッシュボード</h1>
{/* 複数のSuspense境界で並列ストリーミング */}
<div className="grid grid-cols-3 gap-4">
<Suspense fallback={<MetricSkeleton />}>
<RevenueMetric />
</Suspense>
<Suspense fallback={<MetricSkeleton />}>
<UsersMetric />
</Suspense>
<Suspense fallback={<MetricSkeleton />}>
<OrdersMetric />
</Suspense>
</div>
<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
</div>
);
}
async function RevenueMetric() {
const revenue = await fetchRevenue();
return <MetricCard title="売上" value={revenue} />;
}
// 他のコンポーネントも同様...
パフォーマンス測定
Core Web Vitals改善
// app/components/metrics.tsx
'use client';
import { useEffect } from 'react';
export function WebVitals() {
useEffect(() => {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(console.log);
getFID(console.log);
getFCP(console.log);
getLCP(console.log);
getTTFB(console.log);
});
}, []);
return null;
}
ストリーミング効果の測定
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const start = Date.now();
const response = NextResponse.next();
response.headers.set('Server-Timing', `total;dur=${Date.now() - start}`);
return response;
}
まとめ
ストリーミングHTMLとSuspenseを適切に設計することで、以下の効果が得られます。
- TTFB改善: 初期HTMLを即座に送信
- FCP改善: 静的コンテンツを先に表示
- LCP改善: 重要なコンテンツを優先的にストリーミング
- ユーザー体験向上: 段階的な表示で体感速度を改善
React 19とNext.js 15の組み合わせで、これらの最適化がデフォルトで有効になります。Suspense境界を適切に配置し、非同期コンポーネントを活用することが、モダンなWebアプリケーション開発の鍵となります。