コンテンツへスキップ
·16分で読めます

React Server Components:メンタルモデル、パターン、そして落とし穴

React Server Componentsを始めたときに存在してほしかった実践ガイド。メンタルモデル、実際のパターン、境界の問題、そして私の失敗から学べること。

シェア:X / TwitterLinkedIn

React Server Componentsを使い始めて最初の3ヶ月間、自分は理解しているつもりでした。RFCを読み、カンファレンストークを視聴し、いくつかデモアプリも作りました。しかし、ほぼすべてについて自信満々に間違っていたのです。

問題はRSCが複雑だということではありません。メンタルモデルがこれまでのReactとは根本的に異なるということであり、私を含め誰もが、それを従来の枠に当てはめようとしてしまうのです。「SSRみたいなものでしょ」——違います。「PHPみたいなもの?」——近いですが、違います。「サーバーで実行されるコンポーネントでしょ」——技術的には正しいですが、実用的には役に立ちません。

以下は、私が実際に知る必要があったすべてを、誰かにこう説明してほしかったという形で書いたものです。理論的なバージョンではなく、夜11時にシリアライゼーションエラーと格闘しているときになぜかを理解する必要がある、あのバージョンです。

実際に機能するメンタルモデル#

今知っているReactのレンダリングについて、一旦すべて忘れてください。新しい全体像はこうです。

従来のReact(クライアントサイド)では、コンポーネントツリー全体がJavaScriptとしてブラウザに送信されます。ブラウザはそれをダウンロードし、パースし、実行し、結果をレンダリングします。200行のインタラクティブなフォームであろうと、静的なテキスト段落であろうと、すべてのコンポーネントが同じパイプラインを通ります。

React Server Componentsは、これを2つの世界に分割します:

Server Componentsはサーバー上で実行されます。一度実行され、出力を生成し、その結果をクライアントに送信します。コードではありません。ブラウザはコンポーネント関数を見ることも、その依存関係をダウンロードすることも、再レンダリングすることもありません。

Client Componentsは従来のReactと同じように動作します。ブラウザに送信され、ハイドレーションされ、状態を保持し、イベントを処理します。これまで知っていたReactそのものです。

内面化するのに恥ずかしいほど時間がかかった重要な洞察:Server Componentsがデフォルトです。 Next.js App Routerでは、"use client"で明示的にクライアントにオプトインしない限り、すべてのコンポーネントがServer Componentです。これは私たちが慣れていたものとは逆であり、コンポジションについての考え方を変えます。

レンダリングウォーターフォール#

ユーザーがページをリクエストしたとき、実際に何が起こるかを見てみましょう:

1. Request hits the server
2. Server executes Server Components top-down
3. When a Server Component hits a "use client" boundary,
   it stops — that subtree will render on the client
4. Server Components produce RSC Payload (a special format)
5. RSC Payload streams to the client
6. Client renders Client Components, stitching them into
   the server-rendered tree
7. Hydration makes Client Components interactive

ステップ4が最も混乱が生じる部分です。RSC PayloadはHTMLではありません。コンポーネントツリーを記述する特殊なストリーミング形式で、サーバーが何をレンダリングしたか、クライアントがどこで引き継ぐ必要があるか、境界を越えてどのpropsを渡すかを示します。

おおよそこのような形です(簡略化):

M1:{"id":"./src/components/Counter.tsx","chunks":["272:static/chunks/272.js"],"name":"Counter"}
S0:"$Sreact.suspense"
J0:["$","div",null,{"children":[["$","h1",null,{"children":"Welcome"}],["$","$L1",null,{"initialCount":0}]]}]

この形式を暗記する必要はありません。しかし、これが存在すること――サーバーとクライアントの間にシリアライゼーション層があること――を理解しておくと、デバッグの時間を何時間も節約できます。「Propsはシリアライズ可能でなければなりません」というエラーが出るたびに、それは渡そうとしているものがこの変換を通過できないからです。

「サーバーで実行される」の本当の意味#

Server Componentが「サーバーで実行される」と言うとき、文字通りの意味です。コンポーネント関数はNode.js(またはEdgeランタイム)で実行されます。つまり、こんなことができます:

tsx
// app/dashboard/page.tsx — this is a Server Component by default
import { db } from "@/lib/database";
import { headers } from "next/headers";
 
export default async function DashboardPage() {
  const headerList = await headers();
  const userId = headerList.get("x-user-id");
 
  // Direct database query. No API route needed.
  const user = await db.user.findUnique({
    where: { id: userId },
  });
 
  const recentOrders = await db.order.findMany({
    where: { userId: user.id },
    orderBy: { createdAt: "desc" },
    take: 10,
  });
 
  return (
    <div>
      <h1>Welcome back, {user.name}</h1>
      <OrderList orders={recentOrders} />
    </div>
  );
}

useEffectなし。ローディング状態管理なし。つなぎ合わせるためのAPIルートなし。コンポーネントそのものがデータ層です。これがRSCの最大のメリットであり、最初は最も違和感があった部分です。「でも分離はどこに?」とずっと考えていたからです。

分離は"use client"境界です。その上はすべてサーバー。その下はすべてクライアント。それがアーキテクチャです。

サーバー/クライアント境界#

ここが多くの人の理解が崩れるポイントであり、最初の数ヶ月間でデバッグ時間の大半を費やした場所です。

"use client" ディレクティブ#

ファイルの先頭にある"use client"ディレクティブは、そのファイルからエクスポートされるすべてをClient Componentとしてマークします。これはモジュールレベルのアノテーションであり、コンポーネントレベルのものではありません。

tsx
// src/components/Counter.tsx
"use client";
 
import { useState } from "react";
 
// This entire file is now "client territory"
export function Counter({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}
 
// This is ALSO a Client Component because it's in the same file
export function ResetButton({ onReset }: { onReset: () => void }) {
  return <button onClick={onReset}>Reset</button>;
}

よくある間違い:すべてを再エクスポートするバレルファイル(index.ts)に"use client"を付けてしまうこと。おめでとうございます、コンポーネントライブラリ全体をクライアントサイドにしてしまいました。これで200KBのJavaScriptを誤って配信しているチームを実際に見たことがあります。

境界を越えるもの#

これを覚えておけば救われます:サーバー/クライアント境界を越えるものはすべて、JSONにシリアライズ可能でなければなりません。

シリアライズ可能なもの:

  • 文字列、数値、真偽値、null、undefined
  • 配列とプレーンオブジェクト(シリアライズ可能な値を含む)
  • Date(ISO文字列としてシリアライズされる)
  • Server Components(JSXとして――後述します)
  • FormData
  • 型付き配列、ArrayBuffer

シリアライズ不可能なもの:

  • 関数(イベントハンドラを含む)
  • クラス(カスタムクラスのインスタンス)
  • Symbol
  • DOMノード
  • ストリーム(ほとんどの場合)

つまり、こういうことはできません:

tsx
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList"; // Client Component
 
export default async function Page() {
  const items = await getItems();
 
  return (
    <ItemList
      items={items}
      // ERROR: Functions are not serializable
      onItemClick={(id) => console.log(id)}
      // ERROR: Class instances are not serializable
      formatter={new Intl.NumberFormat("en-US")}
    />
  );
}

修正方法は、ページをClient Componentにすることではありません。インタラクティブな部分を下に押し、データフェッチを上に引き上げることです:

tsx
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList";
 
export default async function Page() {
  const items = await getItems();
 
  // Only pass serializable data
  return <ItemList items={items} locale="en-US" />;
}
tsx
// src/components/ItemList.tsx (Client Component)
"use client";
 
import { useState, useMemo } from "react";
 
interface Item {
  id: string;
  name: string;
  price: number;
}
 
export function ItemList({ items, locale }: { items: Item[]; locale: string }) {
  const [selectedId, setSelectedId] = useState<string | null>(null);
 
  // Create the formatter on the client side
  const formatter = useMemo(
    () => new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }),
    [locale]
  );
 
  return (
    <ul>
      {items.map((item) => (
        <li
          key={item.id}
          onClick={() => setSelectedId(item.id)}
          className={selectedId === item.id ? "selected" : ""}
        >
          {item.name} — {formatter.format(item.price)}
        </li>
      ))}
    </ul>
  );
}

「アイランド」の誤解#

最初はClient Componentsを「アイランド」――サーバーレンダリングされたコンテンツの海に浮かぶ小さなインタラクティブな島――だと考えていました。部分的には正しいのですが、重要な詳細を見落としています:Client Componentは、childrenやpropsとして渡された場合、Server Componentsをレンダリングできます。

つまり、境界は硬い壁ではありません。むしろ膜のようなものです。サーバーレンダリングされたコンテンツは、childrenパターンを通じてClient Componentsを通過できます。コンポジションのセクションで詳しく掘り下げます。

データフェッチパターン#

RSCはデータフェッチを根本的に変えます。レンダリング時に判明しているデータに対してuseEffect + useState + ローディング状態はもう不要です。しかし、新しいパターンには独自の落とし穴があります。

基本的なフェッチとキャッシュ#

Server Componentでは、単にfetchするだけです。Next.jsはグローバルなfetchを拡張してキャッシュを追加します:

tsx
// app/products/page.tsx
export default async function ProductsPage() {
  // Cached by default — same URL returns cached result
  const res = await fetch("https://api.example.com/products");
  const products = await res.json();
 
  return (
    <div>
      {products.map((product: Product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

キャッシュの動作を明示的に制御します:

tsx
// Revalidate every 60 seconds (ISR-like behavior)
const res = await fetch("https://api.example.com/products", {
  next: { revalidate: 60 },
});
 
// No caching — always fresh data
const res = await fetch("https://api.example.com/user/profile", {
  cache: "no-store",
});
 
// Cache with tags for on-demand revalidation
const res = await fetch("https://api.example.com/products", {
  next: { tags: ["products"] },
});

そして、Server Actionからタグで再検証できます:

tsx
"use server";
 
import { revalidateTag } from "next/cache";
 
export async function refreshProducts() {
  revalidateTag("products");
}

並列データフェッチ#

最もよく見かけるパフォーマンスの間違い:並列で問題ないのに逐次的にデータフェッチしてしまうこと。

悪い例 ―― 逐次(ウォーターフォール):

tsx
// app/dashboard/page.tsx — DON'T DO THIS
export default async function Dashboard() {
  const user = await getUser();           // 200ms
  const orders = await getOrders();       // 300ms
  const notifications = await getNotifications(); // 150ms
  // Total: 650ms — each waits for the previous one
 
  return (
    <div>
      <UserInfo user={user} />
      <OrderList orders={orders} />
      <NotificationBell notifications={notifications} />
    </div>
  );
}

良い例 ―― 並列:

tsx
// app/dashboard/page.tsx — DO THIS
export default async function Dashboard() {
  // All three fire simultaneously
  const [user, orders, notifications] = await Promise.all([
    getUser(),        // 200ms
    getOrders(),      // 300ms (runs in parallel)
    getNotifications(), // 150ms (runs in parallel)
  ]);
  // Total: ~300ms — limited by the slowest request
 
  return (
    <div>
      <UserInfo user={user} />
      <OrderList orders={orders} />
      <NotificationBell notifications={notifications} />
    </div>
  );
}

さらに良い例 ―― 独立したSuspense境界を使った並列:

tsx
// app/dashboard/page.tsx — BEST
import { Suspense } from "react";
 
export default function Dashboard() {
  // Note: this component is NOT async — it delegates to children
  return (
    <div>
      <Suspense fallback={<UserInfoSkeleton />}>
        <UserInfo />
      </Suspense>
      <Suspense fallback={<OrderListSkeleton />}>
        <OrderList />
      </Suspense>
      <Suspense fallback={<NotificationsSkeleton />}>
        <Notifications />
      </Suspense>
    </div>
  );
}
 
// Each component fetches its own data
async function UserInfo() {
  const user = await getUser();
  return <div>{user.name}</div>;
}
 
async function OrderList() {
  const orders = await getOrders();
  return <ul>{orders.map(o => <li key={o.id}>{o.title}</li>)}</ul>;
}
 
async function Notifications() {
  const notifications = await getNotifications();
  return <span>({notifications.length})</span>;
}

この最後のパターンが最も強力です。各セクションが独立して読み込まれるからです。ユーザーはコンテンツが利用可能になった順に見ることができ、全部か無かではありません。速いセクションは遅いセクションを待ちません。

リクエストの重複排除#

Next.jsは、同じURLとオプションを持つfetch呼び出しを1回のレンダリングパス中に自動的に重複排除します。つまり、冗長なリクエストを避けるためにデータフェッチを巻き上げる必要はありません:

tsx
// Both of these components can fetch the same URL
// and Next.js will only make ONE actual HTTP request
 
async function Header() {
  const user = await fetch("/api/user").then(r => r.json());
  return <nav>Welcome, {user.name}</nav>;
}
 
async function Sidebar() {
  // Same URL — automatically deduped, not a second request
  const user = await fetch("/api/user").then(r => r.json());
  return <aside>Role: {user.role}</aside>;
}

重要な注意点:これはfetchでのみ動作します。ORMやデータベースクライアントを直接使用している場合は、Reactのcache()関数を使う必要があります:

tsx
import { cache } from "react";
import { db } from "@/lib/database";
 
// Wrap your data function with cache()
// Now multiple calls in the same render = one actual query
export const getUser = cache(async (userId: string) => {
  return db.user.findUnique({ where: { id: userId } });
});

cache()は単一のサーバーリクエストの存続期間中に重複排除します。永続的なキャッシュではなく、リクエスト単位のメモ化です。リクエストが完了すると、キャッシュされた値はガベージコレクションされます。

コンポーネントコンポジションパターン#

パターンを理解すれば、RSCが本当にエレガントになる部分です。理解するまでは、本当に混乱する部分でもあります。

「Childrenを穴として使う」パターン#

これはRSCで最も重要なコンポジションパターンであり、その真価を完全に理解するのに数週間かかりました。問題はこうです:レイアウトやインタラクティブ性を提供するClient Componentがあり、その中でServer Componentsをレンダリングしたい。

Client ComponentファイルにServer Componentをインポートすることはできません。"use client"を追加した瞬間、そのモジュール内のすべてがクライアントサイドになります。しかし、Server Componentsをchildrenとして渡すことはできます:

tsx
// src/components/Sidebar.tsx — Client Component
"use client";
 
import { useState } from "react";
 
export function Sidebar({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(true);
 
  return (
    <aside className={isOpen ? "w-64" : "w-0"}>
      <button onClick={() => setIsOpen(!isOpen)}>
        {isOpen ? "Close" : "Open"}
      </button>
      {isOpen && (
        <div className="sidebar-content">
          {/* These children can be Server Components! */}
          {children}
        </div>
      )}
    </aside>
  );
}
tsx
// app/dashboard/layout.tsx — Server Component
import { Sidebar } from "@/components/Sidebar";
import { NavigationLinks } from "@/components/NavigationLinks"; // Server Component
import { UserProfile } from "@/components/UserProfile"; // Server Component
 
export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex">
      <Sidebar>
        {/* These are Server Components, passed through a Client Component */}
        <UserProfile />
        <NavigationLinks />
      </Sidebar>
      <main>{children}</main>
    </div>
  );
}

なぜこれが機能するのでしょうか?Server Components(UserProfileNavigationLinks)がサーバー上で先にレンダリングされ、その出力(RSCペイロード)がClient Componentにchildrenとして渡されるからです。Client Componentはそれらがどこでレンダリングされたかを知る必要がなく、事前レンダリングされたReactノードを受け取るだけです。

childrenを、サーバーレンダリングされたコンテンツが通過できるClient Componentの「穴」と考えてください。

Server Componentsをpropsとして渡す#

childrenパターンは、React.ReactNodeを受け取る任意のpropに一般化できます:

tsx
// src/components/TabLayout.tsx — Client Component
"use client";
 
import { useState } from "react";
 
interface TabLayoutProps {
  tabs: { label: string; content: React.ReactNode }[];
}
 
export function TabLayout({ tabs }: TabLayoutProps) {
  const [activeTab, setActiveTab] = useState(0);
 
  return (
    <div>
      <div className="tab-bar" role="tablist">
        {tabs.map((tab, i) => (
          <button
            key={i}
            role="tab"
            aria-selected={i === activeTab}
            onClick={() => setActiveTab(i)}
            className={i === activeTab ? "active" : ""}
          >
            {tab.label}
          </button>
        ))}
      </div>
      <div role="tabpanel">{tabs[activeTab].content}</div>
    </div>
  );
}
tsx
// app/settings/page.tsx — Server Component
import { TabLayout } from "@/components/TabLayout";
import { ProfileSettings } from "./ProfileSettings";   // Server Component — can fetch data
import { BillingSettings } from "./BillingSettings";    // Server Component — can fetch data
import { SecuritySettings } from "./SecuritySettings";  // Server Component — can fetch data
 
export default function SettingsPage() {
  return (
    <TabLayout
      tabs={[
        { label: "Profile", content: <ProfileSettings /> },
        { label: "Billing", content: <BillingSettings /> },
        { label: "Security", content: <SecuritySettings /> },
      ]}
    />
  );
}

各設定コンポーネントは、独自のデータをフェッチする非同期Server Componentにできます。Client Component(TabLayout)はタブの切り替えだけを処理します。これは非常に強力なパターンです。

非同期Server Components#

Server Componentsはasyncにできます。これは大きなことで、データフェッチが副作用としてではなく、レンダリング中に行われることを意味します:

tsx
// This is valid and beautiful
async function BlogPost({ slug }: { slug: string }) {
  const post = await db.post.findUnique({ where: { slug } });
 
  if (!post) return notFound();
 
  const author = await db.user.findUnique({ where: { id: post.authorId } });
 
  return (
    <article>
      <h1>{post.title}</h1>
      <p>By {author.name}</p>
      <div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
    </article>
  );
}

Client Componentsはasyncできません"use client"コンポーネントをasyncにしようとすると、Reactはエラーを投げます。これはハードな制約です。

Suspense境界:ストリーミングのプリミティブ#

SuspenseはRSCでストリーミングを実現する仕組みです。Suspense境界がなければ、ページ全体が最も遅い非同期コンポーネントを待ちます。Suspense境界があれば、各セクションが独立してストリーミングされます:

tsx
// app/page.tsx
import { Suspense } from "react";
import { HeroSection } from "@/components/HeroSection";
import { ProductGrid } from "@/components/ProductGrid";
import { ReviewCarousel } from "@/components/ReviewCarousel";
import { RecommendationEngine } from "@/components/RecommendationEngine";
 
export default function HomePage() {
  return (
    <main>
      {/* Static — renders immediately */}
      <HeroSection />
 
      {/* Fast data — shows quickly */}
      <Suspense fallback={<ProductGridSkeleton />}>
        <ProductGrid />
      </Suspense>
 
      {/* Medium speed — shows when ready */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ReviewCarousel />
      </Suspense>
 
      {/* Slow (ML-powered) — shows last, doesn't block the rest */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <RecommendationEngine />
      </Suspense>
    </main>
  );
}

ユーザーはHeroSectionを即座に見て、次にProductGridがストリーミングで表示され、続いてレビュー、そしてレコメンデーションの順に表示されます。各Suspense境界が独立したストリーミングポイントです。

Suspense境界のネストも有効で便利です:

tsx
<Suspense fallback={<DashboardSkeleton />}>
  <Dashboard>
    <Suspense fallback={<ChartSkeleton />}>
      <RevenueChart />
    </Suspense>
    <Suspense fallback={<TableSkeleton />}>
      <RecentTransactions />
    </Suspense>
  </Dashboard>
</Suspense>

Dashboardは速いがRevenueChartが遅い場合、外側のSuspenseが先に解決して(ダッシュボードのシェルが表示され)、チャート用の内側のSuspenseが後で解決します。

Error BoundaryとSuspenseの組み合わせ#

Suspenseerror.tsxを組み合わせて、回復力のあるUIを作りましょう:

tsx
// app/dashboard/error.tsx — Client Component (must be)
"use client";
 
export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="p-8 text-center">
      <h2>Something went wrong loading the dashboard</h2>
      <p className="text-gray-500">{error.message}</p>
      <button onClick={reset} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
        Try again
      </button>
    </div>
  );
}

error.tsxファイルは、対応するルートセグメントを自動的にReact Error Boundaryでラップします。そのセグメント内のServer Componentがスローした場合、ページ全体がクラッシュする代わりにエラーUIが表示されます。

いつどちらを使うか:判断フローチャート#

RSCで本番アプリをいくつか構築した後、明確な判断フレームワークに落ち着きました。以下が、すべてのコンポーネントに対して実際に行う思考プロセスです:

Server Componentsから始める(デフォルト)#

すべてのコンポーネントは、Server Componentにできない特定の理由がない限り、Server Componentであるべきです。これが最も重要なルールです。

Client Componentにする場合:#

1. ブラウザ専用APIを使用する場合

tsx
"use client";
// window, document, navigator, localStorage, etc.
function GeoLocation() {
  const [coords, setCoords] = useState<GeolocationCoordinates | null>(null);
 
  useEffect(() => {
    navigator.geolocation.getCurrentPosition(
      (pos) => setCoords(pos.coords)
    );
  }, []);
 
  return coords ? <p>Lat: {coords.latitude}</p> : <p>Loading location...</p>;
}

2. 状態やエフェクトが必要なReact hooksを使用する場合

useStateuseEffectuseReduceruseRef(ミュータブルなref用)、useContext ―― これらのいずれかには"use client"が必要です。

tsx
"use client";
function SearchBar() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 300);
 
  // This component MUST be a Client Component because it
  // uses useState and manages user input
  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search..."
    />
  );
}

3. イベントハンドラを付与する場合

onClickonChangeonSubmitonMouseEnter ―― インタラクティブな動作はすべてクライアントサイドを意味します。

4. クライアントサイドライブラリを使用する場合

Framer Motion、React Hook Form、Zustand、React Query(クライアントサイドフェッチ用)、canvasやSVGにインタラクティブにレンダリングするチャートライブラリなど。

Server Componentのままにする場合:#

  • データを表示するだけ(ユーザーインタラクションなし)
  • データベースやAPIからデータをフェッチする
  • バックエンドリソースにアクセスする(ファイルシステム、シークレットを含む環境変数)
  • クライアントに不要な大きな依存関係をインポートする(マークダウンパーサー、シンタックスハイライター、フォーマット用の日付ライブラリ)
  • 静的または半静的なコンテンツをレンダリングする

実際の判断プロセス#

具体的な例を見てみましょう。プロダクトページを構築しています:

ProductPage (Server)
├── ProductBreadcrumbs (Server) — static navigation, no interactivity
├── ProductImageGallery (Client) — zoom, swipe, lightbox
├── ProductInfo (Server)
│   ├── ProductTitle (Server) — just text
│   ├── ProductPrice (Server) — formatted number, no interaction
│   └── AddToCartButton (Client) — onClick, manages cart state
├── ProductDescription (Server) — rendered markdown
├── Suspense
│   └── RelatedProducts (Server) — async data fetch, slow API
└── Suspense
    └── ProductReviews (Server)
        └── ReviewForm (Client) — form with validation

パターンに注目してください:ページシェルとデータ量の多い部分がServer Componentsです。インタラクティブなアイランド(ImageGalleryAddToCartButtonReviewForm)がClient Componentsです。遅いセクション(RelatedProductsProductReviews)はSuspenseでラップされています。

これは理論ではありません。私のコンポーネントツリーが実際にどう見えるかです。

よくある間違い(すべて経験済み)#

間違い1:すべてをClient Componentにしてしまう#

Pages RouterやCreate React Appから移行するときの最も楽な道は、すべてに"use client"を付けることです。動きます!何も壊れません!しかし、コンポーネントツリー全体をJavaScriptとして配信し、RSCのメリットをゼロにしていることにもなります。

ルートレイアウトに"use client"が付いているコードベースを見たことがあります。その時点で、文字通り余計なステップを踏んだクライアントサイドReactアプリを実行しているだけです。

修正方法:Server Componentsから始めましょう。コンパイラがhookやイベントハンドラを使用しているために必要だと教えてくれたときだけ"use client"を追加します。"use client"をツリーのできるだけ末端に押し下げましょう。

間違い2:境界を越えたPropドリリング#

tsx
// BAD: fetching data in a Server Component, then passing it through
// multiple Client Components
 
// app/page.tsx (Server)
export default async function Page() {
  const user = await getUser();
  const settings = await getSettings();
  const theme = await getTheme();
 
  return (
    <ClientShell user={user} settings={settings} theme={theme}>
      <ClientContent user={user} settings={settings}>
        <ClientWidget user={user} />
      </ClientContent>
    </ClientShell>
  );
}

境界を越えて渡すすべてのデータはRSCペイロードにシリアライズされます。同じオブジェクトを5回渡すと、ペイロードに5回含まれます。このせいでRSCペイロードがメガバイト単位に膨れ上がったのを見たことがあります。

修正方法:コンポジションを使いましょう。データをpropsとして渡す代わりに、Server Componentsをchildrenとして渡します:

tsx
// GOOD: Server Components fetch their own data, pass through as children
 
// app/page.tsx (Server)
export default function Page() {
  return (
    <ClientShell>
      <UserInfo />      {/* Server Component — fetches its own data */}
      <Settings />      {/* Server Component — fetches its own data */}
      <ClientWidget>
        <UserAvatar />  {/* Server Component — fetches its own data */}
      </ClientWidget>
    </ClientShell>
  );
}

間違い3:Suspenseを使わない#

Suspenseがなければ、ページのTime to First Byte(TTFB)は最も遅いデータフェッチに制限されます。ダッシュボードページの読み込みに4秒かかるという問題がありました。1つの分析クエリが遅かっただけなのに、残りのページデータは200msで準備できていたにもかかわらず。

tsx
// BAD: everything waits for everything
export default async function Dashboard() {
  const stats = await getStats();         // 200ms
  const chart = await getChartData();     // 300ms
  const analytics = await getAnalytics(); // 4000ms ← blocks everything
 
  return (
    <div>
      <Stats data={stats} />
      <Chart data={chart} />
      <Analytics data={analytics} />
    </div>
  );
}
tsx
// GOOD: analytics loads independently
export default function Dashboard() {
  return (
    <div>
      <Suspense fallback={<StatsSkeleton />}>
        <Stats />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <Chart />
      </Suspense>
      <Suspense fallback={<AnalyticsSkeleton />}>
        <Analytics /> {/* Takes 4s but doesn't block the rest */}
      </Suspense>
    </div>
  );
}

間違い4:実行時のシリアライゼーションエラー#

これは本番に出るまで気づかないことが多く、特に痛い問題です。境界を越えてシリアライズ不可能な何かを渡すと、不可解なエラーが発生します:

Error: Only plain objects, and a few built-ins, can be passed to
Client Components from Server Components. Classes or null prototypes
are not supported.

よくある原因:

  • Dateオブジェクトを渡す(代わりに.toISOString()を使用)
  • MapやSetを渡す(配列/オブジェクトに変換)
  • ORMのクラスインスタンスを渡す(.toJSON()を使用するか、プレーンオブジェクトにスプレッド)
  • 関数を渡す(ロジックをClient Componentに移動するか、Server Actionsを使用)
  • PrismaモデルのDecimalフィールドの結果を渡す(numberまたはstringに変換)
tsx
// BAD
const user = await prisma.user.findUnique({ where: { id } });
// user might have non-serializable fields (Decimal, BigInt, etc.)
return <ClientProfile user={user} />;
 
// GOOD
const user = await prisma.user.findUnique({ where: { id } });
const serializedUser = {
  id: user.id,
  name: user.name,
  email: user.email,
  balance: user.balance.toNumber(), // Decimal → number
  createdAt: user.createdAt.toISOString(), // Date → string
};
return <ClientProfile user={serializedUser} />;

間違い5:すべてにContextを使ってしまう#

useContextはClient Componentsでのみ動作します。Server ComponentでReact contextを使おうとしても動作しません。テーマcontextを使うためだけにアプリ全体をClient Componentにしているケースを見たことがあります。

修正方法:テーマなどのグローバル状態には、サーバーサイドで設定するCSS変数を使用するか、cookies()/headers()関数を使用します:

tsx
// app/layout.tsx (Server Component)
import { cookies } from "next/headers";
 
export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const cookieStore = await cookies();
  const theme = cookieStore.get("theme")?.value ?? "light";
 
  return (
    <html data-theme={theme} className={theme === "dark" ? "dark" : ""}>
      <body>{children}</body>
    </html>
  );
}

本当にクライアントサイドの状態(認証トークン、ショッピングカート、リアルタイムデータ)が必要な場合は、ルートではなく適切なレベルに薄いClient Componentプロバイダーを作成します:

tsx
// src/providers/CartProvider.tsx
"use client";
 
import { createContext, useContext, useState } from "react";
 
const CartContext = createContext<CartContextType | null>(null);
 
export function CartProvider({ children }: { children: React.ReactNode }) {
  const [items, setItems] = useState<CartItem[]>([]);
 
  return (
    <CartContext.Provider value={{ items, setItems }}>
      {children}
    </CartContext.Provider>
  );
}
 
export function useCart() {
  const context = useContext(CartContext);
  if (!context) throw new Error("useCart must be used within CartProvider");
  return context;
}
tsx
// app/shop/layout.tsx (Server Component)
import { CartProvider } from "@/providers/CartProvider";
 
export default function ShopLayout({ children }: { children: React.ReactNode }) {
  // CartProvider is a Client Component, but children flow through as server content
  return <CartProvider>{children}</CartProvider>;
}

間違い6:バンドルサイズへの影響を無視する#

RSCの最大のメリットの一つは、Server Componentのコードがクライアントに送信されないことです。しかし、これは意識的に考える必要があります。50KBのマークダウンパーサーを使用し、レンダリングされたコンテンツを表示するだけのコンポーネントがある場合、それはServer Componentであるべきです。パーサーはサーバーに留まり、HTML出力だけがクライアントに送られます。

tsx
// Server Component — marked stays on the server
import { marked } from "marked"; // 50KB library — never ships to client
 
async function BlogContent({ slug }: { slug: string }) {
  const post = await getPost(slug);
  const html = marked(post.markdown);
 
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

これをClient Componentにしたら、markedがブラウザに送信されます。何の意味もなく。最初からHTMLにできたコンテンツをレンダリングするためだけに、ユーザーは50KBのJavaScriptをダウンロードすることになります。

@next/bundle-analyzerでバンドルを確認してください。結果に驚くかもしれません。

キャッシュ戦略#

Next.js 15以降のキャッシュは以前のバージョンと比べて大幅に簡素化されましたが、理解すべき異なるレイヤーがまだあります。

cache()関数(React)#

Reactのcache()はリクエスト単位の重複排除用であり、永続的なキャッシュではありません:

tsx
import { cache } from "react";
 
export const getCurrentUser = cache(async () => {
  const session = await getSession();
  if (!session) return null;
 
  return db.user.findUnique({ where: { id: session.userId } });
});
 
// Call this anywhere in your component tree during a single request.
// Only one actual database query will execute.

これは単一のサーバーリクエストにスコープされています。リクエストが完了すると、キャッシュされた値は消えます。キャッシュではなくメモ化です。

unstable_cache(Next.js)#

リクエスト間の永続的なキャッシュには、unstable_cacheを使用します(名前はずっと「unstable」のままですが、本番環境で問題なく動作します):

tsx
import { unstable_cache } from "next/cache";
 
const getCachedProducts = unstable_cache(
  async (categoryId: string) => {
    return db.product.findMany({
      where: { categoryId },
      include: { images: true },
    });
  },
  ["products-by-category"], // cache key prefix
  {
    revalidate: 3600, // revalidate every hour
    tags: ["products"], // for on-demand revalidation
  }
);
 
// Usage in a Server Component
async function ProductGrid({ categoryId }: { categoryId: string }) {
  const products = await getCachedProducts(categoryId);
  return <Grid items={products} />;
}

無効化するには:

tsx
"use server";
 
import { revalidateTag } from "next/cache";
 
export async function onProductUpdate() {
  revalidateTag("products");
}

静的 vs 動的レンダリング#

Next.jsは、ルート内で使用されているものに基づいて、静的か動的かを判断します:

静的(ビルド時にレンダリング、キャッシュ):

  • 動的関数(cookies()headers()searchParams)を使用しない
  • すべてのfetch呼び出しでキャッシュが有効
  • export const dynamic = "force-dynamic"がない

動的(リクエストごとにレンダリング):

  • cookies()headers()、またはsearchParamsを使用
  • fetchcache: "no-store"を使用
  • export const dynamic = "force-dynamic"がある
  • next/serverconnection()またはafter()を使用

next buildを実行すると、どのルートが静的か動的かを確認できます。下部に凡例が表示されます:

Route (app)                    Size     First Load JS
┌ ○ /                          5.2 kB   92 kB
├ ○ /about                     1.1 kB   88 kB
├ ● /blog/[slug]               2.3 kB   89 kB
├ λ /dashboard                 4.1 kB   91 kB
└ λ /api/products              0 B      87 kB

○ Static   ● SSG   λ Dynamic

キャッシュの階層構造#

キャッシュをレイヤーで考えましょう:

1. React cache()          — per-request, in-memory, automatic dedup
2. fetch() cache          — cross-request, automatic for GET requests
3. unstable_cache()       — cross-request, for non-fetch operations
4. Full Route Cache       — rendered HTML cached at build/revalidation time
5. Router Cache (client)  — in-browser cache of visited routes

各レイヤーは異なる目的を持っています。すべてが常に必要なわけではありませんが、どれがアクティブかを理解しておくと、「なぜデータが更新されないのか?」という問題のデバッグに役立ちます。

実際のキャッシュ戦略#

本番環境で私が実際にやっていることはこうです:

tsx
// lib/data/products.ts
 
import { cache } from "react";
import { unstable_cache } from "next/cache";
import { db } from "@/lib/database";
 
// Per-request dedup: call this multiple times in one render,
// only one DB query runs
export const getProductById = cache(async (id: string) => {
  return db.product.findUnique({
    where: { id },
    include: { category: true, images: true },
  });
});
 
// Cross-request cache: results persist across requests,
// revalidate every 5 minutes or on-demand via tag
export const getPopularProducts = unstable_cache(
  async () => {
    return db.product.findMany({
      orderBy: { salesCount: "desc" },
      take: 20,
      include: { images: true },
    });
  },
  ["popular-products"],
  { revalidate: 300, tags: ["products"] }
);
 
// No caching: always fresh (for user-specific data)
export const getUserCart = cache(async (userId: string) => {
  // cache() here is only for per-request dedup, not persistence
  return db.cart.findUnique({
    where: { userId },
    include: { items: { include: { product: true } } },
  });
});

経験則:低頻度で変更される公開データにはunstable_cache。ユーザー固有のデータには重複排除のみのcache()。リアルタイムデータにはキャッシュなし。

Server Actions:サーバーへの橋渡し#

Server ActionsはRSCの物語を完成させるものなので、独自のセクションに値します。Client ComponentsがAPIルートなしでサーバーと通信する手段です。

tsx
// app/actions/newsletter.ts
"use server";
 
import { db } from "@/lib/database";
import { z } from "zod";
 
const emailSchema = z.string().email();
 
export async function subscribeToNewsletter(formData: FormData) {
  const email = formData.get("email");
 
  const result = emailSchema.safeParse(email);
  if (!result.success) {
    return { error: "Invalid email address" };
  }
 
  try {
    await db.subscriber.create({
      data: { email: result.data },
    });
    return { success: true };
  } catch (e) {
    if (e instanceof Error && e.message.includes("Unique constraint")) {
      return { error: "Already subscribed" };
    }
    return { error: "Something went wrong" };
  }
}
tsx
// src/components/NewsletterForm.tsx
"use client";
 
import { useActionState } from "react";
import { subscribeToNewsletter } from "@/app/actions/newsletter";
 
export function NewsletterForm() {
  const [state, formAction, isPending] = useActionState(
    async (_prev: { error?: string; success?: boolean } | null, formData: FormData) => {
      return subscribeToNewsletter(formData);
    },
    null
  );
 
  return (
    <form action={formAction}>
      <input
        type="email"
        name="email"
        required
        placeholder="you@example.com"
        disabled={isPending}
      />
      <button type="submit" disabled={isPending}>
        {isPending ? "Subscribing..." : "Subscribe"}
      </button>
      {state?.error && <p className="text-red-500">{state.error}</p>}
      {state?.success && <p className="text-green-500">Subscribed!</p>}
    </form>
  );
}

Server Actionsは、RSCの世界で「どうやってデータを変更するのか?」に対する答えです。フォーム送信、ミューテーション、副作用のほとんどのAPIルートを置き換えます。

Server Actionsの重要なルール:

  • 入力は必ずバリデーションする(関数はクライアントから呼び出し可能――APIエンドポイントと同じように扱う)
  • 常にシリアライズ可能なデータを返す
  • サーバー上で実行されるため、データベース、ファイルシステム、シークレットにアクセスできる
  • ミューテーション後にキャッシュされたデータを更新するためにrevalidatePath()revalidateTag()を呼び出せる

マイグレーションパターン#

既存のReactアプリ(Pages Router、Create React App、Vite)がある場合、RSCへの移行はリライトである必要はありません。以下が私のアプローチです。

ステップ1:コンポーネントの分類#

コンポーネントツリーを確認し、すべてを分類します:

Component            State?  Effects?  Events?  → Decision
─────────────────────────────────────────────────────────
Header               No      No        No       → Server
NavigationMenu       No      No        Yes      → Client (mobile toggle)
Footer               No      No        No       → Server
BlogPost             No      No        No       → Server
SearchBar            Yes     Yes       Yes      → Client
ProductCard          No      No        Yes      → Client (onClick) or split
UserAvatar           No      No        No       → Server
CommentForm          Yes     Yes       Yes      → Client
Sidebar              Yes     No        Yes      → Client (collapse toggle)
MarkdownRenderer     No      No        No       → Server (big dependency win)
DataTable            Yes     Yes       Yes      → Client (sorting, filtering)

ステップ2:データフェッチを上に移動する#

最大のアーキテクチャ変更は、コンポーネント内のuseEffectからasync Server Componentsへのデータフェッチの移動です。ここが本当のマイグレーション作業の中心です。

Before:

tsx
// Old pattern — data fetching in a Client Component
"use client";
 
function ProductPage({ id }: { id: string }) {
  const [product, setProduct] = useState<Product | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    fetch(`/api/products/${id}`)
      .then(res => res.json())
      .then(data => setProduct(data))
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, [id]);
 
  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  if (!product) return <NotFound />;
 
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={product.id} />
    </div>
  );
}

After:

tsx
// New pattern — Server Component fetches, Client Component interacts
 
// app/products/[id]/page.tsx (Server Component)
import { db } from "@/lib/database";
import { notFound } from "next/navigation";
import { AddToCartButton } from "@/components/AddToCartButton";
 
export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await db.product.findUnique({ where: { id } });
 
  if (!product) notFound();
 
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={product.id} price={product.price} />
    </div>
  );
}

ローディング状態管理なし。エラー状態なし。useEffectなし。フレームワークがSuspenseとError Boundaryを通じてすべてを処理します。

ステップ3:インタラクション境界でコンポーネントを分割する#

多くのコンポーネントはほぼ静的で、小さなインタラクティブ部分があるだけです。それらを分割しましょう:

Before(一つの大きなClient Component):

tsx
"use client";
 
function ProductCard({ product }: { product: Product }) {
  const [isFavorite, setIsFavorite] = useState(false);
 
  return (
    <div className="card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.description}</p>
      <span>${product.price}</span>
      <button onClick={() => setIsFavorite(!isFavorite)}>
        {isFavorite ? "♥" : "♡"}
      </button>
    </div>
  );
}

After(小さなClientアイランドを持つServer Component):

tsx
// ProductCard.tsx (Server Component)
import { FavoriteButton } from "./FavoriteButton";
 
function ProductCard({ product }: { product: Product }) {
  return (
    <div className="card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.description}</p>
      <span>${product.price}</span>
      <FavoriteButton productId={product.id} />
    </div>
  );
}
tsx
// FavoriteButton.tsx (Client Component)
"use client";
 
import { useState } from "react";
 
export function FavoriteButton({ productId }: { productId: string }) {
  const [isFavorite, setIsFavorite] = useState(false);
 
  return (
    <button
      onClick={() => setIsFavorite(!isFavorite)}
      aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
    >
      {isFavorite ? "♥" : "♡"}
    </button>
  );
}

画像、タイトル、説明、価格はサーバーレンダリングされるようになりました。小さなお気に入りボタンだけがClient Componentです。JavaScriptが少なくなり、ページの読み込みが速くなります。

ステップ4:APIルートをServer Actionsに変換する#

自分のフロントエンドだけにサービスを提供する(外部利用者がいない)APIルートがある場合、そのほとんどはServer Actionsにできます:

Before:

tsx
// app/api/contact/route.ts
export async function POST(request: Request) {
  const body = await request.json();
  // validate, save to DB, send email
  return Response.json({ success: true });
}
 
// Client component
const response = await fetch("/api/contact", {
  method: "POST",
  body: JSON.stringify(formData),
});

After:

tsx
// app/actions/contact.ts
"use server";
 
export async function submitContactForm(formData: FormData) {
  // validate, save to DB, send email
  return { success: true };
}
 
// Client component — just call the function directly
import { submitContactForm } from "@/app/actions/contact";

APIルートを維持するケース:Webhook、外部APIの利用者、カスタムHTTPヘッダーやステータスコードが必要なもの、ストリーミングを伴うファイルアップロード。

ステップ5:RSCコンポーネントのテスト#

Server Componentsのテストは、asyncにできるため少し異なるアプローチが必要です:

tsx
// __tests__/ProductPage.test.tsx
import { render, screen } from "@testing-library/react";
import ProductPage from "@/app/products/[id]/page";
 
// Mock the database
vi.mock("@/lib/database", () => ({
  db: {
    product: {
      findUnique: vi.fn(),
    },
  },
}));
 
describe("ProductPage", () => {
  it("renders product details", async () => {
    const { db } = await import("@/lib/database");
    vi.mocked(db.product.findUnique).mockResolvedValue({
      id: "1",
      name: "Test Product",
      description: "A great product",
      price: 29.99,
    });
 
    // Server Components are async — await the JSX
    const jsx = await ProductPage({
      params: Promise.resolve({ id: "1" })
    });
    render(jsx);
 
    expect(screen.getByText("Test Product")).toBeInTheDocument();
    expect(screen.getByText("A great product")).toBeInTheDocument();
  });
});

重要な違い:コンポーネント関数をasyncなのでawaitします。そして結果のJSXをレンダリングします。それ以外は従来のReact Testing Libraryと同じです。

RSCプロジェクトのファイル構成#

いくつかのプロジェクトを経て、私が落ち着いた構成がこちらです。意見が入っていますが、うまく機能します:

src/
├── app/
│   ├── layout.tsx              ← Root layout (Server Component)
│   ├── page.tsx                ← Home page (Server Component)
│   ├── (marketing)/            ← Route group for marketing pages
│   │   ├── about/page.tsx
│   │   └── pricing/page.tsx
│   ├── (app)/                  ← Route group for authenticated app
│   │   ├── layout.tsx          ← App shell with auth check
│   │   ├── dashboard/
│   │   │   ├── page.tsx
│   │   │   ├── loading.tsx     ← Suspense fallback for this route
│   │   │   └── error.tsx       ← Error boundary for this route
│   │   └── settings/
│   │       └── page.tsx
│   └── actions/                ← Server Actions
│       ├── auth.ts
│       └── products.ts
├── components/
│   ├── ui/                     ← Shared UI primitives (mostly Client)
│   │   ├── Button.tsx          ← "use client"
│   │   ├── Dialog.tsx          ← "use client"
│   │   └── Card.tsx            ← Server Component (just styling)
│   └── features/               ← Feature-specific components
│       ├── products/
│       │   ├── ProductGrid.tsx     ← Server (async, fetches data)
│       │   ├── ProductCard.tsx     ← Server (presentational)
│       │   ├── ProductSearch.tsx   ← Client (useState, input)
│       │   └── AddToCart.tsx       ← Client (onClick, mutation)
│       └── blog/
│           ├── PostList.tsx        ← Server (async, fetches data)
│           ├── PostContent.tsx     ← Server (markdown rendering)
│           └── CommentSection.tsx  ← Client (form, real-time)
├── lib/
│   ├── data/                   ← Data access layer
│   │   ├── products.ts         ← cache() wrapped DB queries
│   │   └── users.ts
│   ├── database.ts
│   └── utils.ts
└── providers/
    ├── ThemeProvider.tsx        ← "use client" — wraps parts that need theme
    └── CartProvider.tsx         ← "use client" — wraps shop section only

主要な原則:

  • Server Componentsにはディレクティブがない ―― デフォルトだから
  • Client Componentsは明示的にマークされる ―― 一目でわかる
  • データフェッチはlib/data/にある ―― cache()またはunstable_cacheでラップ
  • Server Actionsはapp/actions/にある ―― アプリと同じ場所に、明確に分離
  • プロバイダーは必要最小限をラップする ―― アプリ全体ではない

まとめ#

React Server Componentsは単なる新しいAPIではありません。コードがどこで実行され、データがどこに存在し、パーツがどう接続されるかについての異なる考え方です。メンタルモデルの転換は本物であり、時間がかかります。

しかし、一度クリックすると――境界と戦うのをやめ、それを中心に設計し始めると――以前作っていたものよりも速く、シンプルで、メンテナンスしやすいアプリが出来上がります。クライアントに送信されるJavaScriptが少なくなります。データフェッチにセレモニーが不要になります。コンポーネントツリーがそのままアーキテクチャになります。

移行する価値はあります。ただし、最初のいくつかのプロジェクトは不快に感じるものであり、それは正常です。RSCが悪いから苦労しているのではありません。本当に新しいものだから苦労しているのです。

すべてServer Componentsから始めましょう。"use client"を末端に押し下げましょう。遅いものはSuspenseでラップしましょう。データはレンダリングされる場所でフェッチしましょう。childrenを通じてコンポーズしましょう。

それがプレイブック全体です。あとは細部に過ぎません。

関連記事