Next.js App Router が登場してから RSC を本格的に触る機会が増えてきた。ドキュメントを読んだだけではピンとこなかった部分が、実際にコードを書いてみて初めて理解できたことが多かったので設計上のポイントをまとめる。
Server Component と Client Component の概念整理
最初に混乱したのは「Server Component はサーバーでしかレンダリングされない」という当たり前の事実をちゃんと意識できていなかった点だ。
- Server Component: サーバーでのみ実行。
useState・useEffect・イベントハンドラは使えない。DB や内部 API へのアクセスが直接できる - Client Component: ファイルの先頭に
'use client'を宣言。ブラウザで実行される従来の React コンポーネント
// app/posts/page.tsx(Server Component)
export default async function PostsPage() {
const posts = await getPosts(); // DB に直接アクセスできる
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
async/await がそのまま使えるので、データフェッチのコードがシンプルになる。useEffect の中で fetch を呼んでいた頃に比べると見通しが段違いによい。
境界でハマったポイント
一番ハマったのが、Server Component の中に Client Component をネストするときの制約だ。'use client' を宣言した時点で、そのコンポーネントより下のツリーはすべてクライアント側になる。Client Component の中に Server Component を直接インポートして使うことはできない。
// ❌ Client Component 内に Server Component を直接置けない
'use client';
import ServerChild from './ServerChild';
export default function ClientParent() {
return <ServerChild />; // クライアント扱いになってしまう
}
// ✅ children 経由で composition する
'use client';
export default function ClientParent({ children }) {
return <div onClick={...}>{children}</div>;
}
// 呼び出し側(Server Component)
export default function Page() {
return (
<ClientParent>
<ServerChild /> {/* Server Component として動作する */}
</ClientParent>
);
}
この children パターンに慣れるまで少し時間がかかった。「どこで境界を引くか」という設計の判断が RSC を使う上で最も重要なスキルだと感じる。
データフェッチの考え方が変わった
Pages Router 時代は getServerSideProps にデータフェッチを集約していたが、App Router では各コンポーネントが直接データを取得できる。コンポーネントの責務という観点では自然な設計だが、N+1 問題やウォーターフォールには注意が必要だ。並列フェッチが必要な場合は Promise.all を使う。
実用上の制約として感じたこと
useState を内部で使っているサードパーティライブラリは Client Component でしか使えない。'use client' が宣言されていないライブラリはラッパーを自分で用意する必要があり、UI ライブラリを導入するたびにこの問題に当たった。また、Server/Client の境界を誤るとエラーが出るが、メッセージが難解でどのコンポーネントが問題かすぐにわからないことがある。
設計指針としてまとめると
RSC を使う上で意識しているのは「インタラクションが必要なものだけ Client Component にする」という方針だ。デフォルトは Server Component、useState やイベントハンドラが必要になった時点で 'use client' を追加する。境界の設計は最初が肝心で、後から変えようとするとリファクタリングコストが意外と高い。