言語を切り替える
テーマを切り替える

React Server Components パフォーマンス最適化:データフェッチとキャッシュの実践

もしあなたの RSC ページの TTFB がまだ 300-500ms のままであれば、その性能ポテンシャルの 30% 程度しか引き出せていないかもしれません。実測データによると、適切なストリーミングアーキテクチャを使用することで TTFB を 45ms まで下げることができます。これは魔法ではありません。React Server Components のストリーミングレンダリングが真に有効化された結果です。

正直に言うと、私もこの落とし穴にはまりました。昨年、ある EC チームの商品詳細ページの最適化を手伝った際、彼らは Next.js App Router を使用していましたが、TTFB は 380ms 程度で安定していました。調査の結果、ネストされたコンポーネントがそれぞれデータを取得し、典型的な「ウォーターフォール」が形成されていることが判明しました。商品情報がレビューを待ち、レビューが価格データを待ち、価格が在庫確認を待つ状態です。9 秒間のホワイトスクリーン。

この記事では、この問題をどう解決するかについてお話しします。4つのウォーターフォール対処方法を比較し、5つのキャッシュ API の使用シーンを詳しく解説し、そのままコピーできる設定テンプレートを提供します。TTFB を 450ms から 45ms まで下げるには、数個の Suspense 境界の位置調整だけが必要かもしれません。


ウォーターフォール問題:RSC パフォーマンスの最大の敵

まず実例を見てみましょう。EC サイトで商品詳細ページを開くと、ページはまず商品名を表示し、3秒待って価格が現れ、さらに5秒待ってコメント欄が表示されます。ユーザー体験は?災難です。

これがウォーターフォール問題です。ネストされたコンポーネントがそれぞれデータを取得し、並列ではなく順次実行されます。React Server Components のデータフェッチはデフォルトで同期的にブロックされます。await を含むリクエストはすべてレンダリングをブロックします。Suspense で囲まない限り。

ウォーターフォールの2つの形態

1つ目:Server 内部ウォーターフォール。同一ページ内で、親コンポーネントがデータを取得した後に子コンポーネントをレンダリングし、子コンポーネントが自分のデータを取得します。典型的なコード:

// ウォーターフォールの例——これは問題のあるコードです
async function ProductPage({ id: string }) {
  // 最初のリクエスト:1秒
  const product = await db.getProduct(id);

  // 子コンポーネントがレンダリングされた後にこれらのリクエストが開始
  return (
    <div>
      <ProductDetails product={product} />
      <ProductPrice id={id} />      {/* 内部で await getPrice(id)、3秒 */}
      <ProductReviews id={id} />    {/* 内部で await getReviews(id)、5秒 */}
    </div>
  );
}

// ProductPrice.tsx
async function ProductPrice({ id }) {
  const price = await getPrice(id);  // 親コンポーネントのレンダリング後に実行
  return <span>{price}</span>;
}

総所要時間?9秒。ユーザーは空白のページを9秒間見つめることになります。

2つ目:Client-Server ウォーターフォール。クライアントコンポーネントがサーバーにリクエストし、サーバーがさらにデータベースにリクエストします。これはより隠密で、React DevTools Profiler でのみ捉えられます。N+1 問題の変種です。

ウォーターフォールの見分け方

React DevTools Profiler を開き、ページロードを1回録画します。タイムラインに明確な階段状のリクエスト分布があれば、各リクエストが前の完了を待っている状態、それがウォーターフォールです。

より直感的な方法もあります。ブラウザの Network パネルを開き、リクエストの開始時間を見ます。データリクエストが集中的ではなく分散して発行されている場合、問題はほぼ確定です。

興味深いことに、多くの開発者は RSC を使用すれば自動的にパフォーマンス向上が得られると思っています。実際にはそうではありません。SitePoint 2026年のレポートによると、ほとんどのチームは RSC のパフォーマンスポテンシャルの約30%しか引き出せていません。原因はウォーターフォールへの対処不足です。


4つの解決方法比較:荒削りからエレガントまで

ウォーターフォール問題には4つの主流な解決方法があります。シンプルから複雑、荒削りからエレガントまで。

方法1:Promise.all 並列フェッチ

最も直接的なアプローチです。すべてのリクエストを同時に発行し、Promise.all ですべての完了を待ちます。

// 方法1:Promise.all 並列フェッチ
async function ProductPage({ id: string }) {
  // すべてのリクエストを同時に発行
  const [product, price, reviews] = await Promise.all([
    getProduct(id),      // 1秒
    getPrice(id),        // 3秒
    getReviews(id),      // 5秒
  ]);

  return (
    <div>
      <ProductDetails product={product} />
      <ProductPriceDisplay price={price} />
      <ProductReviewsList reviews={reviews} />
    </div>
  );
}

総所要時間?5秒。最も遅いリクエストが全体の時間を決定します。

メリット:シンプル、変更が少ない。

デメリット:ユーザーは最も遅いリクエストの完了を待たないと何も見られません。もう1つの問題はデータ結合です。親コンポーネントが子コンポーネントに必要なデータを知る必要があり、コンポーネントの独立性の原則に違反します。

方法2:Suspense 境界分離

データ依存部分に Suspense を追加し、重要なコンテンツを先に表示します。

// 方法2:Suspense 境界分離
async function ProductPage({ id: string }) {
  const product = await getProduct(id);  // 重要データを先に待つ

  return (
    <div>
      <ProductDetails product={product} />  {/* 1秒後に表示 */}
      
      {/* 重要でない部分を Suspense で囲む */}
      <Suspense fallback={<PriceSkeleton />}>
        <ProductPrice id={id} />
      </Suspense>
      
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews id={id} />
      </Suspense>
    </div>
  );
}

ユーザー体験:1秒で商品情報を見られ、3秒で価格が現れ、5秒でコメントの読み込みが完了します。

メリット:重要なコンテンツが優先的に表示され、ユーザー体験が良い。

デメリット:データリクエストは依然として順次発行される。ProductPrice と ProductReviews のリクエストは親コンポーネントのレンダリング後に開始され、真の並列ではありません。

方法3:Promise を Props として渡す

親コンポーネントですべてのリクエストを開始し、Promise を props として子コンポーネントに渡します。子コンポーネントは自分で await します。

// 方法3:Promise 渡しパターン
async function ProductPage({ id: string }) {
  // すべてのリクエストを即座に開始、await しない
  const productPromise = getProduct(id);
  const pricePromise = getPrice(id);
  const reviewsPromise = getReviews(id);

  // 重要データのみ await
  const product = await productPromise;

  return (
    <div>
      <ProductDetails product={product} />
      
      <Suspense fallback={<PriceSkeleton />}>
        <ProductPrice pricePromise={pricePromise} />
      </Suspense>
      
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews reviewsPromise={reviewsPromise} />
      </Suspense>
    </div>
  );
}

// ProductPrice.tsx——Promise を受け取る
async function ProductPrice({ pricePromise }) {
  const price = await pricePromise;  // 親コンポーネントが開始した Promise を再利用
  return <span>{price}</span>;
}

3つのリクエストが親コンポーネントで同時に開始されます。重要データは1秒で表示され、価格は3秒、コメントは5秒です。

メリット:すべてのリクエストが並列で発行され、重要なコンテンツが優先的に表示され、データが分離される(子コンポーネントは Promise のみ受け取る)。

デメリット:コンポーネントのインターフェースを変更する必要があり、子コンポーネントは id から Promise を受け取るように変わる。

方法4:React cache() + preload(推奨)

React 19 で cache() API が導入されました。preload パターンと組み合わせることで、最もエレガントな解決方法になります。

// 方法4:React cache() + preload
import { cache } from 'react';

// cache でデータフェッチ関数をラップ
const getComments = cache(async (postId: string) => {
  return db.getComments(postId);
});

// preload 関数をエクスポート、用途を明確に示す
export const preloadComments = (id: string) => {
  void getComments(id);  // await しない、開始だけする
};

// 親コンポーネント
async function PostPage({ postId: string }) {
  preloadComments(postId);  // コメントをプリロード
  
  const post = await getPost(postId);  // 重要データのみ await
  
  return (
    <div>
      <PostContent post={post} />
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments postId={postId} />  {/* id をそのまま使用、cache が自動再利用 */}
      </Suspense>
    </div>
  );
}

// Comments.tsx——コンポーネントインターフェースは変更なし
async function Comments({ postId }) {
  const comments = await getComments(postId);  // preload の Promise を再利用
  return <CommentList comments={comments} />;
}

仕組み:cache() 関数は同一レンダリングサイクル内で自動的にメモ化されます。preload 呼び出し時にリクエストがトリガーされますが待機せず、子コンポーネントが await する際に同じ Promise を再利用します。

メリット

  • コンポーネントインターフェースが変わらない(引き続き id を渡す)
  • リクエストが自動的にメモ化され、データ結合なし
  • 子コンポーネント削除時、preload が無用なコードになり、発見しやすい

デメリット:cache() の仕組みを理解する必要があり、隠れた結合に注意(Comments コンポーネント削除時に preload も削除)。

4つの方法の比較

方法総所要時間重要コンテンツ表示時間データ結合変更コスト
順次取得9s9sなしなし
Promise.all5s5sあり
Suspense5s1sなし
Promise 渡し5s1s解消
cache() + preload1s1sなし

実際の選択はチームの状況によります。迅速な移行には方法2を、新規プロジェクトには方法4を推奨します。


ストリーミングレンダリングアーキテクチャ:TTFB 45ms の秘密

従来の SSR のワークフローはこのようになります。すべてのデータフェッチの完了を待ち、完全な HTML をレンダリングし、ブラウザに一度に送信します。TTFB(Time to First Byte)は、データフェッチ時間にレンダリング時間を加えたものです。

具体的な数字:データベースクエリ 400ms、レンダリング 50ms、TTFB は約 450ms。ユーザーは約半秒間空白のページを見つめることになります。

450ms → 45ms
TTFB 最適化効果

RSC Streaming がこのフローをどう変えるか

ストリーミングレンダリングの核心は、何かを減らすことではなく、コンテンツがユーザーに届く順序を変えることです。静的部分は即座に送信され、動的部分はストリーミングで補完されます。

// ストリーミングアーキテクチャの例
export default async function Dashboard() {
  return (
    <Layout>                          {/* 静的 shell、Suspense で囲まない */}
      <Nav />                         {/* 即座にレンダリング */}
      <Sidebar />                     {/* 即座にレンダリング */}
      
      <Suspense fallback={<ChartSkeleton />}>
        <DynamicChart />               {/* 動的データ、ストリーミング読み込み */}
      </Suspense>
      
      <Suspense fallback={<TableSkeleton />}>
        <DataTable />                  {/* 動的データ、ストリーミング読み込み }}
      </Suspense>
    </Layout>
  );
}

ワークフローの分解:

  1. T=0ms:静的 shell(Layout、Nav、Sidebar)が CDN エッジキャッシュから即座に送信
  2. T=30-50ms:ブラウザが静的 shell のレンダリングを開始、スケルトンを表示
  3. T=200ms:DynamicChart のデータフェッチ完了、対応する Suspense 境界のコンテンツがストリーミング送信
  4. T=400ms:DataTable のデータフェッチ完了、対応するコンテンツがストリーミング送信

TTFB?約 45ms。静的 shell の送信時間です。

PPR(Partial Prerendering)の役割

PPR は Next.js 15 で導入された機能で、Next.js 16 ではデフォルトで有効になります。静的部分を CDN にプリレンダリングし、動的部分はストリーミングを維持します。

設定方法:

// next.config.js——Next.js 15
module.exports = {
  experimental: {
    ppr: true,  // PPR を有効化
  },
};

// next.config.js——Next.js 16(プレビュー)
module.exports = {
  experimental: {
    ppr: 'incremental',  // 段階的に有効化
    cacheComponents: true,  // 新しいキャッシュモデル
  },
};

PPR を有効にすると、静的 shell(ナビゲーション、レイアウト、スケルトン)が CDN にプリレンダリングされて保存されます。ユーザーがアクセスすると、CDN は即座に静的 HTML を返し、動的部分はサーバーからストリーミングで補完されます。

Suspense 境界設計の原則

重要な原則:Suspense でストリーミングブロックをマークし忘れると、React はアプリケーション全体を1つの巨大なブロックとして扱います。

正しいアプローチ:

  • 静的部分は Suspense で囲まない:ナビゲーション、Layout、データに依存しないスケルトン
  • 動的部分は Suspense で囲む:データベースや API に依存するコンポーネント
// 正しい例
export default async function Page() {
  return (
    <>
      <Header />                     {/* 静的、囲まない */}
      <main>
        <Suspense fallback={<HeroSkeleton />}>
          <HeroSection />             {/* 動的、囲む */}
        </Suspense>
        
        <Suspense fallback={<ContentSkeleton />}>
          <MainContent />             {/* 動的、囲む */}
        </Suspense>
      </main>
      <Footer />                      {/* 静的、囲まない */}
    </>
  );
}

間違った例(ページ全体がブロック):

// 間違った例——Suspense 忘れ
export default async function Page() {
  const data = await fetchDashboard();  // await がページ全体をブロック
  return (
    <>
      <Header />
      <Dashboard data={data} />
      <Footer />
    </>
  );
}

Suspense 境界がないと、ページ全体が1つのストリーミングブロックとして扱われます。TTFB は依然として 450ms です。

パフォーマンスデータの比較

レンダリングモードTTFBLCP説明
従来の SSR~450ms~500msすべてのデータを待つ
RSC(Suspense なし)~450ms~500ms従来の SSR と同等
RSC Streaming~45ms~200ms静的 shell を即座に送信
RSC + PPR~30ms~150msCDN が静的 shell をキャッシュ

データ出典:SitePoint 2026年レポート、実測データはデータソースや CDN 設定により異なる可能性があります。


5つのキャッシュ API 使用ガイド

Next.js と React は5つのキャッシュメカニズムを提供しています。正しく選べば効果倍増、間違えれば重複リクエストが発生する可能性があります。

1. fetch cache(最も一般的)

fetch リクエストは Server Components で自動的にメモ化されます。同一レンダリングサイクル内で、同じ URL とパラメータのリクエストは一度だけ発行されます。

// fetch cache の例
async function ProductCard({ id }) {
  // 自動キャッシュ、同じ URL は重複リクエストしない
  const res = await fetch(`https://api.example.com/products/${id}`, {
    cache: 'force-cache',      // 強制キャッシュ(デフォルト)
    next: {
      revalidate: 3600,         // 1時間後に再検証
      tags: ['products'],       // タグ、revalidateTag で使用
    },
  });
  return <Card data={res.json()} />;
}

async function ProductList() {
  // このリクエストは上記のキャッシュを再利用
  const res = await fetch('https://api.example.com/products', {
    next: { tags: ['products'] },
  });
  return <List data={res.json()} />;
}

設定オプション

  • cache: 'force-cache':キャッシュ優先(デフォルト)
  • cache: 'no-store':毎回リクエスト
  • next.revalidate:定期的な再検証(秒)
  • next.tags:タグ、revalidateTag で手動リフレッシュ

2. React cache()(React 19 新規)

関数呼び出し結果のキャッシュに使用します。データベースクエリ、カスタムデータフェッチ関数に適しています。

import { cache } from 'react';

// cache でデータベースクエリをラップ
export const getUser = cache(async (id: string) => {
  const user = await db.query('SELECT * FROM users WHERE id = ?', [id]);
  return user;
});

// 複数のコンポーネントで使用、自動的にメモ化
async function UserProfile({ id }) {
  const user = await getUser(id);
  return <Profile user={user} />;
}

async function UserStats({ id }) {
  const user = await getUser(id);  // 上記の結果を再利用
  return <Stats user={user} />;
}

注意:cache() は同一レンダリングサイクル内でのみ有効です。リクエストを跨ぐには unstable_cache を使用します。

3. unstable_cache(Next.js 14-15)

永続キャッシュ、リクエストを跨いで保持します。高価な計算、ページを跨ぐデータ共有に適しています。

import { unstable_cache } from 'next/cache';

// 関数をラップ、永続キャッシュを追加
export const getPopularProducts = unstable_cache(
  async () => {
    const products = await db.getPopularProducts();
    return products;
  },
  ['popular-products'],           // キャッシュキー
  {
    revalidate: 3600,              // 1時間で再検証
    tags: ['products', 'popular'], // 複数タグ
  }
);

// 使用
async function HomePage() {
  const products = await getPopularProducts();
  return <ProductGrid products={products} />;
}

手動リフレッシュ

import { revalidateTag } from 'next/cache';

// Server Action または API Route 内で
async function updateProduct() {
  await db.updateProduct();
  await revalidateTag('products');  // すべての products タグのキャッシュをリフレッシュ
}

4. use cache(Next.js 16 新規)

コンポーネントレベルのキャッシュディレクティブ。関数またはコンポーネントの先頭に ‘use cache’ を追加すると、出力が自動的にキャッシュされます。

// 関数レベル 'use cache'
'use cache';
export async function getRecommendations(userId: string) {
  return db.getRecommendations(userId);
}

// コンポーネントレベル 'use cache'
'use cache';
export async function CachedFooter() {
  const links = await getFooterLinks();
  return <Footer links={links} />;
}

適用シーン:頻繁にアクセスされるコンポーネント、静的コンテンツ。実験的機能、Next.js 16 で正式サポート。

5. revalidatePath / revalidateTag

手動でキャッシュをリフレッシュする方法。

import { revalidatePath, revalidateTag } from 'next/cache';

// パスでリフレッシュ
await revalidatePath('/products');      // そのパスのすべてのキャッシュをリフレッシュ
await revalidatePath('/products/[id]', 'page');  // 特定のページをリフレッシュ

// タグでリフレッシュ
await revalidateTag('products');        // すべての products タグのキャッシュをリフレッシュ

選択の推奨

  • 精密な制御には revalidateTag(推奨)
  • 一括リフレッシュには revalidatePath

キャッシュ API の比較

APIキャッシュ範囲永続化適用シーンバージョン
fetch cache単一リクエスト設定可能API リクエストNext.js 13+
React cache()単一レンダリングサイクルなしデータベースクエリ、カスタム関数React 19
unstable_cacheリクエスト間あり高価な計算、共有データNext.js 14-15
use cache関数/コンポーネントレベルあり頻繁にアクセスされるコンポーネントNext.js 16
Cache Componentsコンポーネント出力ありPPR と組み合わせNext.js 16

実践設定テンプレートとよくある落とし穴

理論は説明したので、すぐにコピーできる設定を提供します。

next.config.js 完全設定

// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    // Next.js 15:PPR を有効化
    ppr: true,
    
    // Next.js 16:新しいキャッシュモデル
    // cacheComponents: true,  // 正式版で有効化
  },
  
  // パフォーマンス関連
  images: {
    formats: ['image/avif', 'image/webp'],
  },
  
  // 出力最適化
  output: 'standalone',  // Docker デプロイ用
};

export default nextConfig;

preload 関数エクスポートの規範

preload 関数は隠れた結合を生みやすいです。深い階層の子コンポーネントを削除する際、preload が無用なコードになる可能性があります。

推奨:preload 関数の上にコメントを追加し、用途を明記します。

// comments.ts
import { cache } from 'react';

const getComments = cache(async (postId: string) => {
  return db.getComments(postId);
});

/**
 * preloadComments:Comments コンポーネントのコメントデータをプリロード
 * 注意:Comments コンポーネント削除時はこの preload 関数も削除すること
 */
export const preloadComments = (id: string) => {
  void getComments(id);
};

export async function Comments({ postId }) {
  const comments = await getComments(postId);
  return <CommentList comments={comments} />;
}

よくある落とし穴事例

落とし穴1:Suspense 境界忘れ

症状:ページ全体の TTFB が依然として 450ms、ストリーミング効果なし。

原因:Suspense 境界がないと、React はページ全体を1つのストリーミングブロックとして扱う。

解決:データ依存コンポーネントに Suspense を追加。

// 修正前
async function Page() {
  const data = await getData();  // ページ全体をブロック
  return <Dashboard data={data} />;
}

// 修正後
async function Page() {
  return (
    <Suspense fallback={<DashboardSkeleton />}>
      <Dashboard />
    </Suspense>
  );
}

落とし穴2:preload したが未使用

症状:リクエストが発行されたがデータが使われず、リソースの無駄遣い。

原因:子コンポーネントを削除したが preload 関数は残した。

解決:コンポーネント削除時に preload も削除するか、コメントで関連性をマークする。

落とし穴3:キャッシュ tags の競合

症状:revalidateTag のリフレッシュ範囲が広すぎ、リフレッシュすべきでないデータもリフレッシュされる。

原因:関連性のない複数のキャッシュが同じ tag を使用している。

解決:異なるビジネスのデータには異なる tag を使用する。

// 間違った例
await fetch(url, { next: { tags: ['data'] } });  // すべてのデータが 'data' tag
await revalidateTag('data');  // すべてのデータキャッシュをリフレッシュ

// 正しい例
await fetch(productsUrl, { next: { tags: ['products'] } });
await fetch(usersUrl, { next: { tags: ['users'] } });
await revalidateTag('products');  // products のみリフレッシュ

落とし穴4:fetch cache と React cache の混用

症状:同じデータで2回リクエストが発生する。

原因:fetch で ‘no-store’ を使用、React cache() が再利用できない。

解決:fetch は force-cache またはデフォルト設定を使用し、React cache が再利用できるようにする。

// 間違った例
const data1 = await fetch(url, { cache: 'no-store' });  // キャッシュしない
const data2 = await getData();  // React cache でラップ、再利用不可

// 正しい例
const data1 = await fetch(url);  // デフォルト force-cache
const data2 = await getData();  // 再利用可能

デバッグツール

  1. React DevTools Profiler:レンダリングプロセスを録画、ウォーターフォール分布を確認
  2. Next.js 分析ツールnext build --experimental-debug でビルド分析を出力
  3. Chrome DevTools:Network パネルでリクエストタイミングを確認、Performance パネルでレンダリングタイミングを確認

重要な指標:

  • TTFB:First Byte Time、目標 < 100ms
  • LCP:Largest Contentful Paint、目標 < 2.5s
  • CLS:Cumulative Layout Shift、目標 < 0.1

移行の推奨

既存の SSR ページから RSC ストリーミングへの移行:

  1. まずウォーターフォールを特定:Profiler で録画、順次データフェッチを見つける
  2. Suspense 境界を追加:データ依存コンポーネントに Suspense を追加、重要パスを優先
  3. preload を追加:深いコンポーネントに cache() + preload を使用
  4. キャッシュを設定:fetch に tags を追加、精密なリフレッシュ制御を実現

段階的に移行し、一度にリファクタリングしない。まず最も遅いページを処理し、効果を測定してから拡大する。


まとめ

これだけ説明しましたが、核心は3つのステップです。ウォーターフォールを特定し、解決方法を選択し、ストリーミングアーキテクチャを設定する。

ウォーターフォール問題は簡単に特定できます。ネストされたコンポーネントがそれぞれデータを await し、タイムライン上で階段状に分布しています。4つの解決方法はそれぞれ適用シーンがあります。迅速な移行には Suspense 境界を、新規プロジェクトには React cache() + preload を使用します。

ストリーミングアーキテクチャの鍵は Suspense 境界の位置です。静的部分(ナビゲーション、Layout)は囲まず、動的部分(データ依存コンポーネント)は必ず囲みます。この境界を忘れると、ページ全体がブロックレンダリングされます。

パフォーマンス向上は定量化できます。TTFB は 450ms から 45ms へ、10倍の差です。間にあるのは数個の Suspense 境界と1つの preload 関数だけです。

今すぐ Next.js プロジェクトを開き、ネストされたコンポーネントがそれぞれデータを取得していないか確認してください。TTFB がまだ 300ms 以上あるなら、重要なコンテンツを Suspense で囲んでみてください。ユーザーはすぐに違いを感じるはずです。

React Server Components パフォーマンス最適化プロセス

ウォーターフォール特定からストリーミングアーキテクチャ設定までの完全な最適化ステップ

⏱️ 目安時間: 60 分

  1. 1

    ステップ1: ウォーターフォール問題を特定

    React DevTools Profiler を使用してページロードを録画:

    • Chrome DevTools を開き、Profiler タブに切り替える
    • 録画をクリック、ページを更新、ロード完了を待つ
    • タイムライン上のリクエスト分布を確認
    • 階段状の分布 = ウォーターフォール問題
    • TTFB が 300ms を超えていないか確認
  2. 2

    ステップ2: 解決方法を選択

    チームの状況に応じて選択:

    • 迅速な移行:方法2(Suspense 境界分離)
    • 新規プロジェクト:方法4(React cache() + preload)
    • データ結合を許容:方法1(Promise.all)
    • コンポーネントインターフェース維持:方法4(推奨)
  3. 3

    ステップ3: Suspense 境界を追加

    データ依存コンポーネントに Suspense を追加:

    • 静的部分は囲まない(ナビゲーション、Layout)
    • 動的部分は必ず囲む(データ依存コンポーネント)
    • 適切な fallback スケルトンを提供
    • 重要パスを優先的に処理
  4. 4

    ステップ4: React cache() + preload を設定

    React 19 cache() API を使用:

    • cache でデータフェッチ関数をラップ
    • preload 関数をエクスポート、await しない
    • コメントで関連性をマーク
    • コンポーネント削除時に preload も削除
  5. 5

    ステップ5: キャッシュ戦略を設定

    適切なキャッシュ API を選択:

    • fetch cache:API リクエスト(最も一般的)
    • React cache():データベースクエリ
    • unstable_cache:リクエスト間共有
    • キャッシュに tags を追加、精密なリフレッシュを実現
  6. 6

    ステップ6: パフォーマンス向上を測定

    最適化効果を検証:

    • TTFB 目標:< 100ms
    • LCP 目標:< 2.5s
    • CLS 目標:< 0.1
    • 最適化前後のパフォーマンスデータを比較

FAQ

React Server Components のウォーターフォール問題とは何ですか?
ウォーターフォール問題とは、ネストされたコンポーネントが順次データを取得し、ページロード時間が累積することです。例えば、親コンポーネントがデータを取得した後に子コンポーネントをレンダリングし、子コンポーネントがさらにデータを取得すると、総所要時間は全リクエスト時間の合計になります。React DevTools Profiler を使用すると、階段状のリクエスト分布を特定できます。
4つのウォーターフォール解決方法をどう選択しますか?
チームの状況に応じて選択:

• Promise.all:シンプルで直接、迅速な修正に適するが、データ結合あり
• Suspense 境界:重要コンテンツを優先表示、迅速な移行に適する
• Promise 渡し:すべてのリクエストが並列、コンポーネントインターフェース変更可能な場合に適する
• React cache() + preload:最もエレガントな方法、新規プロジェクトに適する(推奨)
Suspense 境界はどこに配置すべきですか?
重要な原則:静的部分は Suspense で囲まず、動的部分は必ず囲みます。静的部分にはナビゲーション、Layout、データに依存しないスケルトンが含まれ、動的部分にはデータベースや API に依存するコンポーネントが含まれます。囲み忘れるとページ全体がブロックされます。
5つのキャッシュ API の違いは何ですか?
適用シーンが異なります:

• fetch cache:API リクエスト、自動メモ化(Next.js 13+)
• React cache():データベースクエリ、単一レンダリングサイクルキャッシュ(React 19)
• unstable_cache:リクエスト間永続化、高価な計算(Next.js 14-15)
• use cache:関数/コンポーネントレベルキャッシュ(Next.js 16)
• revalidatePath/Tag:手動キャッシュリフレッシュ
RSC パフォーマンス最適化効果をどう測定しますか?
React DevTools Profiler を使用してレンダリングプロセスを録画し、ウォーターフォール分布を確認します。重要な指標:TTFB 目標は 100ms 未満、LCP 目標は 2.5s 未満、CLS 目標は 0.1 未満です。最適化後、TTFB は 450ms から 45ms まで下がります。
PPR(Partial Prerendering)とは何ですか?
PPR は Next.js 15 で導入された機能で、Next.js 16 ではデフォルトで有効になります。静的部分を CDN にプリレンダリングし、動的部分はストリーミングを維持します。有効にすると、静的 shell(ナビゲーション、レイアウト)が CDN から即座に返され、TTFB は 30ms まで下がります。設定:experimental.ppr = true。
よくあるキャッシュ設定の落とし穴は何ですか?
4つの一般的な問題:

• Suspense 境界忘れ:ページ全体がブロック、TTFB 改善なし
• preload したが未使用:コンポーネント削除時に preload が残り、リクエストの無駄
• キャッシュ tags 競合:revalidateTag のリフレッシュ範囲が広すぎる
• fetch cache と React cache 混用:no-store 使用で再利用不可

参考資料

8 min read · 公開日: 2026年5月13日 · 更新日: 2026年5月13日

関連記事

コメント

GitHubアカウントでログインしてコメントできます