React 19 と Server Components:実践的ディープガイド
我々のチームが最初に踩した RSC の穴
去年、古い CRA アプリを Next.js App Router に移行した。自信満々でページ全体を非同期コンポーネントにし、その中で直接 await fetch を書いた。ローカルでは動いた——しかしステージングに上げると白紙になった。30 分調べて判明した原因は、onClick を使ったボタンとその親をサーバー側に置いたままにしていたことだ。サーバーコンポーネントには「イベント」という概念そのものが存在しない。
そのインシデントでひとつ分かった。RSC は「書き方を変える」ものではない。「どのコードがどのマシンで動くか」をもう一度考え直すものだ。これまでに踩した穴と、検証済みのやり方をここにまとめる。
1. React 19 で何が変わったのか
まずは純クライアント側の変更から。これらは RSC とは無関係だが、移行時には必ず出くわす。
use():ついに Promise も Context も読める
use() はレンダー中に Promise を直接 await でき、Context も読める。そして条件文の中でも呼べる(これが useContext との最大の違いだ)。
import { use } from "react";
function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
const comments = use(commentsPromise); // useEffect なしで結果を直接取得
return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
}
Actions:「送信 → 待機 → 更新」がファーストクラスに
以前は isPending、useState、フォームのリセットを自前で管理した。今は useActionState + サーバー Action で一気に片付く:
"use server";
async function subscribe(prevState: { ok: boolean }, formData: FormData) {
const email = formData.get("email");
// サーバーで動くので DB/シークレットに触れる
await db.users.subscribe(String(email));
return { ok: true };
}
"use client";
import { useActionState } from "react";
function Form() {
const [state, formAction, pending] = useActionState(subscribe, { ok: false });
return (
<form action={formAction}>
<input name="email" />
<button disabled={pending}>{pending ? "送信中…" : "購読"}</button>
{state.ok && <p>購読しました</p>}
</form>
);
}
useOptimistic:楽観的更新を手書きしない
const [optimistic, addOptimistic] = useOptimistic(
messages,
(cur, next: string) => [...cur, { text: next, sending: true }]
);
小さいけれど嬉しい 2 つの変更
refを普通の prop として渡せる。forwardRefは不要に(旧 API も動く)。- ネイティブの
<form>、<meta>、<title>、<link>をコンポーネント内に直接書ける。React が自動で<head>に持ち上げる。
2. RSC とは何か:実行モデル
Server Component はビルド時またはサーバーでレンダーされる。そのコードはブラウザのバンドルに入らない。返すのは DOM ではなく、特殊なシリアライズ形式(RSC Payload)だ。それをクライアントの React が組み立てる。
直感に反する点:
- サーバーコンポーネントは巨大なサーバー専用ライブラリ(DB ドライバなど)を
importできる。そのコードはそもそも配信されないからだ。 - サーバーコンポーネントには
useState、useEffect、ブラウザイベントはない。リクエストごとに一度だけ走る。 - クライアントコンポーネント(
"use client"付き)は JS にバンドルされ、サーバー側でも初期 HTML として事前レンダーされる。
メンタルモデル:
リクエスト ──▶ サーバーコンポーネントツリー(Node で実行)
│ "use client" 境界にぶつかる
▼
RSC Payload をシリアライズ ──▶ ブラウザ
│ クライアントコンポーネントが hydrate
▼
インタラクティブなページ
3. 'use client' 境界の引き方
移行で最も頭を使う箇所だ。原則はひとつ:クライアント境界は葉ノードにできるだけ近づける。
// サーバーコンポーネント(既定)
import { db } from "@/server/db";
import LikeButton from "./LikeButton"; // これはクライアントコンポーネント
export default async function Post({ id }: { id: string }) {
const post = await db.posts.find(id); // 直接 DB を叩き、安全
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<LikeButton initial={post.likes} /> {/* このボタンだけが対話を必要とする */}
</article>
);
}
"use client";
export default function LikeButton({ initial }: { initial: number }) {
const [likes, setLikes] = useState(initial);
return <button onClick={() => setLikes(v => v + 1)}>👍 {likes}</button>;
}
よくある失敗:ページ全体を "use client" にする。そうすると純 CSR に逆戻りし、RSC の利益(バンドル削減、データソース直結)が全部消える。本当に対話やブラウザ API、useState が必要な最小のコンポーネントだけに "use client" を付ける。
4. サーバーコンポーネント内で直接データを取る
もう getServerSideProps のような儀式は不要だ。コンポーネント自体が async になる:
export default async function Dashboard() {
const [orders, users] = await Promise.all([
fetch("https://api/internal/orders").then(r => r.json()),
fetch("https://api/internal/users").then(r => r.json()),
]);
return <Charts orders={orders} users={users} />;
}
注意:クライアントコンポーネントに渡す props はシリアライズ可能でなければならない。関数、Map、クラスインスタンスは不可。振る舞いを渡したい?その関数はクライアントコンポーネントの内側で定義する。
5. Suspense によるストリーミング
重いクエリが遅くても、ページ全体を待たせる必要はない。Suspense で遅い部分を「くり抜く」:
import { Suspense } from "react";
export default function Page() {
return (
<main>
<h1>レポート</h1>
<Suspense fallback={<Skeleton />}>
<SlowReport /> {/* このコンポーネント内で重いクエリを await */}
</Suspense>
</main>
);
}
サーバーはまず <h1> とスケルトンを送出し、SlowReport のデータができたらその「穴」を埋める(ストリーミング)。体感される初回描画はずっと速くなる。
6. キャッシュ:fetch は既定でキャッシュされる
RSC 環境では fetch は既定でキャッシュされる(cache: 'force-cache' 相当)。読みが多い場面では好都合だが、切り方を知っておく:
fetch(url, { cache: "no-store" }); // 常に最新
fetch(url, { next: { revalidate: 60 } }); // 60 秒以内はキャッシュを使う
fetch 以外の計算をリクエスト間で共有したいなら React.cache() を使う:
import { cache } from "react";
export const getUser = cache(async (id: string) => {
return db.users.find(id); // 同一レンダー内で何度呼んでも DB は 1 回
});
7. 実際のプロジェクト移行で踩した穴
- Context はクライアントコンポーネントからしか下に流せない。 サーバーコンポーネントで Context Provider を定義するとエラーになる——Provider はクライアント境界の内側に置く。
- イベントハンドラはサーバーコンポーネントから下に渡せない。
onClickが必要?その層はクライアントコンポーネントにする。 window/document/localStorageはサーバー側では undefined。 ブラウザ API へのアクセスは"use client"コンポーネント、useEffect、またはイベントハンドラの中に置く。- サードパーティ製ライブラリが必ずしも互換とは限らない。 "exports were not found" やハイドレーションエラーが出たら、
"use client"対応版がないか確認するか、自分のクライアントコンポーネントで包んでから import する。 - サーバーコンポーネントに
useStateを書くな。 コンパイルすら通らず、境界を間違えている合図になる。
8. RSC を使うべきでないとき
RSC は銀の弾丸ではない。以下の場面では純クライアントのほうが向いている:
- 対話性が強くデータが軽いツール系ページ(リッチテキストエディタ、お絵かき、IDE 風)。
- ほぼ静的でバックエンド依存のないマーケティングページ(SSG/静的エクスポートのほうが軽い)。
- チームが SSR のメンタルモデルに馴染む前に期限に追われているとき——無理に RSC を使うと、遅くて保守困難な「キメラ」になりがち。
よくある質問
Q1:RSC と SSR は同じ?
違う。SSR はクライアントコンポーネントをサーバーで HTML にレンダーする(コードはバンドルに残る)。RSC はコンポーネントがブラウザに届かず、サーバーで動く。両者はよく併用される。RSC がデータ/構造を、SSR が初回 HTML を担う。
Q2:クライアントコンポーネントの props に何故関数を渡せない?
props は RSC Payload のシリアライズを経るが、関数はシリアライズできないからだ。振る舞いが必要?クライアントコンポーネントの内側で定義するか、Actions 経由でサーバー側ロジックを呼ぶ。
Q3:「use client」をファイル先頭に書くと、そのファイルの全コンポーネントがクライアントになる?
そうなる。ファイルには境界はひとつだけで、"use client" はモジュール全体をクライアントモジュールにする。だからクライアントコンポーネントは小さな別ファイルにし、サーバーコンポーネントから import する。
Q4:React 19 は古いプロジェクトにそのまま使える?
クライアント機能(use、useOptimistic 等)は React のバージョンアップだけで良い。しかし RSC を得るには対応フレームワーク(Next.js App Router や RSC 対応のメタフレームワーク)が必要だ。生 webpack プロジェクトでは RSC は使えない。
Q5:ハイドレーション不一致をどう切り分ける?
典型的な症状は、サーバー側でレンダーした文字列とクライアントの初回レンダーが一致しないこと(タイムスタンプ、乱数、toLocaleString 等)。こうした値は useEffect 内で再代入するか、クライアント依存情報にはスケルトン占位を使う。
おすすめツール
RSC やコンポーネントと格闘するとき、ToolsKu の以下のツールが役立つ:
- オンラインコード実行 — TSX スニペットの実行時挙動を手早く検証
- Mermaid プレビュー — クライアント/サーバーコンポーネントツリーをフローチャートにしてチームで共有
- JSON フォーマッター — サーバーコンポーネントから渡される props が何にシリアライズされたかを確認
RSC の本質は、「コードはどこで動くか」という SPA 時代に隠された問いを、再びあなたの目の前に置くことだ。境界を正しく描けばバンドル削減の救世主になり、描き間違えば新たな混沌の源泉になる。
ブラウザローカルツールを無料で試す →