React Server Components(RSC)が登場したとき、正直よくわからなかった。「サーバーでレンダリングするのはSSRと同じじゃないの?」「どこに書けばいいの?」と混乱したのは僕だけじゃないはずだ。
この記事では、RSCの仕組みをゼロから理解して、Client Componentsとの使い分けをマスターするところまで連れていく。Next.js App Routerを使った実践パターンも含めて、「なんとなく動いている」から「理由を理解して書ける」状態に変わるはずだ。
RSCとは何か?SSRと何が違う?
RSCを一言で説明すると「サーバー上でのみ実行され、クライアントにJavaScriptが送られないコンポーネント」だ。
SSRとRSCの違い
SSR(Server-Side Rendering)は従来からある仕組みで、サーバーでHTMLを生成してクライアントに送り、そのあとJavaScriptを読み込んでReactが「乗っ取る」(ハイドレーション)。クライアントにはコンポーネントのJavaScriptコードが全て送られる。
RSCはこれとは根本的に違う。
| 比較軸 | SSR | RSC |
|---|---|---|
| 実行場所 | サーバー(HTML生成)+ クライアント(ハイドレーション) | サーバーのみ |
| クライアントへのJS送信 | あり(コンポーネントコードが全て含まれる) | なし |
| インタラクティビティ | 可能(ハイドレーション後) | 不可 |
| データベース直接アクセス | 工夫が必要 | そのまま可能 |
| バンドルサイズへの影響 | ある | ゼロ |
RSCのコンポーネントはサーバーで実行されてHTMLに変換され、JavaScriptとしてはクライアントに届かない。つまり、import したnpmライブラリ(たとえば重い日付処理ライブラリ)もバンドルサイズに加算されない。
RSCが解決する問題
// ❌ 従来のアプローチ(クライアントでデータフェッチ)
"use client";
import { useEffect, useState } from "react";
function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch("/api/products").then(res => res.json()).then(setProducts);
}, []);
return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
このアプローチの問題:
- クライアントで追加のリクエストが発生する(ウォーターフォール)
- ローディング状態の管理が必要
- コンポーネントのJSがバンドルに含まれる
// ✅ RSCを使ったアプローチ
// app/products/page.tsx(デフォルトでServer Component)
import { db } from "@/lib/db";
async function ProductList() {
// データベースに直接アクセス。APIレイヤー不要
const products = await db.product.findMany();
return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
- 追加のリクエストなし(サーバーで直接DBアクセス)
- ローディング状態の管理不要
- コンポーネントJSはクライアントに届かない
Server ComponentとClient Componentの使い分け
Next.js App Routerでは、デフォルトで全コンポーネントはServer Componentになる。Client Componentにするには "use client" ディレクティブが必要だ。
Client Componentが必要なとき
"use client"; // これがないとエラーになる
import { useState, useEffect } from "react";
// ✅ Client Componentが必要なケース
export function Counter() {
const [count, setCount] = useState(0); // stateが必要
return (
<button onClick={() => setCount(c => c + 1)}>
カウント: {count}
</button>
);
}
Client Componentを使うべき場面:
useState、useReducer、useEffectなどのHooksを使う- ブラウザのAPIにアクセスする(
window、localStorage、navigatorなど) - クリック・フォーム送信・スクロールなどのイベントハンドラを使う
- アニメーション(Framer Motionなど)を使う
Server Componentが向いているとき
// app/blog/[slug]/page.tsx
import { db } from "@/lib/db";
import { marked } from "marked"; // 重い変換ライブラリもバンドルサイズゼロ
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// DBに直接アクセス
const post = await db.post.findUnique({
where: { slug },
});
if (!post) return <div>記事が見つかりません</div>;
// markedをサーバーでだけ使う。クライアントにJSは送られない
const html = marked(post.content);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: html }} />
</article>
);
}
Server Componentが向いている場面:
- データベースやAPIからデータを取得する
- 認証トークン・APIキーなどの秘密情報にアクセスする
- 大きなnpmライブラリをバンドルサイズなしで使う
- HTMLをレンダリングするだけでインタラクションが不要
Server ComponentとClient Componentの組み合わせ方
重要なルールがある。Server ComponentはClient Componentの子として渡せるが、Client ComponentはServer Componentを直接インポートできない。
❌ できないパターン
"use client";
// ❌ Client Component内でServer Componentをimportしてはいけない
import { ServerSideWidget } from "./ServerSideWidget"; // これはエラー
export function ClientShell() {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(o => !o)}>開く</button>
{open && <ServerSideWidget />} {/* ❌ Client内でServer Componentは動かない */}
</div>
);
}
✅ 正しいパターン(children propで渡す)
// ClientShell.tsx
"use client";
import { ReactNode, useState } from "react";
export function ClientShell({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(o => !o)}>開く</button>
{open && children} {/* ✅ childrenとして受け取る */}
</div>
);
}
// app/page.tsx(Server Component)
import { ClientShell } from "./ClientShell";
import { ServerSideWidget } from "./ServerSideWidget"; // Server Component
export default function Page() {
return (
// ✅ Server ComponentでCompositionする
<ClientShell>
<ServerSideWidget />
</ClientShell>
);
}
この「childrenパターン」はRSCを使ううえで最重要のパターンだ。Client Componentの中に「穴」(children)を作って、そこにServer Componentを渡す。
実際のプロジェクトでの構成例
app/
├── page.tsx # Server Component(データ取得)
├── components/
│ ├── ProductCard.tsx # Server Component(静的な表示)
│ ├── AddToCart.tsx # "use client"(クリックハンドラが必要)
│ └── SearchBox.tsx # "use client"(inputのstate管理)
// app/page.tsx
import { db } from "@/lib/db";
import { ProductCard } from "./components/ProductCard";
import { SearchBox } from "./components/SearchBox";
export default async function HomePage() {
const products = await db.product.findMany({ take: 10 });
return (
<main>
<SearchBox /> {/* Client Component */}
<div className="grid grid-cols-3 gap-4">
{products.map(product => (
<ProductCard key={product.id} product={product}>
{/* AddToCartはClient Componentだが、productデータはサーバーから渡す */}
<AddToCart productId={product.id} />
</ProductCard>
))}
</div>
</main>
);
}
Server Actionと組み合わせる
RSCはServer Actionとセットで使うことが多い。フォームの送信やデータの変更をサーバー側の関数として定義できる。
// app/actions.ts
"use server"; // Server Action
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function addProduct(formData: FormData) {
const name = formData.get("name") as string;
const price = Number(formData.get("price"));
await db.product.create({ data: { name, price } });
revalidatePath("/products"); // キャッシュを無効化して再レンダリング
}
// app/products/new/page.tsx(Server Component)
import { addProduct } from "../actions";
export default function NewProductPage() {
return (
<form action={addProduct}>
<input name="name" placeholder="商品名" />
<input name="price" type="number" placeholder="価格" />
<button type="submit">追加</button>
</form>
);
}
Suspenseとストリーミング
RSCはReactのSuspenseと組み合わせてストリーミングレンダリングができる。重いデータ取得があっても、その部分だけ遅延させてUIを段階的に表示できる。
// app/dashboard/page.tsx
import { Suspense } from "react";
import { HeavyStats } from "./components/HeavyStats"; // 重いデータ取得
export default function DashboardPage() {
return (
<div>
<h1>ダッシュボード</h1>
{/* すぐ表示 */}
<QuickSummary />
{/* HeavyStatsのデータ取得が完了するまでfallbackを表示 */}
<Suspense fallback={<div>統計情報を読み込み中...</div>}>
<HeavyStats />
</Suspense>
</div>
);
}
// app/dashboard/components/HeavyStats.tsx(Server Component)
async function HeavyStats() {
// 重いデータ取得(2秒かかるとしても、ページ全体をブロックしない)
const stats = await fetchExpensiveStats();
return (
<div>
<p>総売上: {stats.revenue}</p>
<p>ユーザー数: {stats.users}</p>
</div>
);
}
ページ全体のロードをブロックすることなく、UIを段階的にクライアントに届けられる。
まとめ
React Server Componentsは「JavaScriptをクライアントに送らないコンポーネント」だ。
| 使うべき場面 | Server Component | Client Component |
|---|---|---|
| データフェッチ | ✅ | 避ける |
| DB直接アクセス | ✅ | ❌ 不可 |
| 秘密情報の使用 | ✅ | ❌ 危険 |
| useState/useEffect | ❌ 不可 | ✅ |
| イベントハンドラ | ❌ 不可 | ✅ |
| ブラウザAPI | ❌ 不可 | ✅ |
App Routerでは「デフォルトServer、必要なときだけClient」の思考で進めると自然に最適な構成になる。迷ったらServer Componentを選び、Hooksやイベントハンドラが必要になったタイミングで "use client" を追加すればいい。
childrenパターンを使いこなせれば、インタラクティブな要素とサーバー側のデータ取得を両立できる。RSCはバンドルサイズの削減とデータフェッチの簡略化に大きく貢献するので、App Routerを使うなら積極的に活用したい。