React Server Components深掘り — RSCの仕組みと設計パターン
React Server Componentsとは
React Server Components(RSC)は、コンポーネントをサーバー側でレンダリングし、その結果をクライアントに送信する新しいReactのアーキテクチャです。
従来のSSR(Server-Side Rendering)と異なり、RSCはコンポーネント単位でサーバー/クライアントを選択でき、JavaScriptバンドルサイズを大幅に削減できます。
SSRとRSCの違い
従来のSSR
1. サーバーでHTML生成
2. クライアントにHTMLを送信
3. JavaScriptバンドル全体をダウンロード
4. Hydration(再実行して状態を復元)
RSC
1. サーバーでコンポーネントを実行
2. シリアライズされた結果をストリーミング
3. 必要なClient Componentのみをバンドル
4. 選択的Hydration
重要な違い: RSCのコードはクライアントに送信されないため、バンドルサイズが削減されます。
RSCのシリアライゼーション
RSCは、コンポーネントツリーを特殊なJSONフォーマットでシリアライズします。
シリアライズされるもの
// Server Component
async function UserProfile({ userId }) {
const user = await db.user.findUnique({ where: { userId } })
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)
}
これは以下のようなフォーマットでクライアントに送信されます。
[
"div",
null,
["h1", null, "John Doe"],
["p", null, "john@example.com"]
]
シリアライズできないもの
- 関数(イベントハンドラを含む)
- クラスインスタンス
- Date、Map、Setなどの複雑なオブジェクト
これらが必要な場合は、Client Componentを使用します。
Server ComponentとClient Componentの境界
基本ルール
// app/page.tsx - Server Component(デフォルト)
import { ClientButton } from './client-button'
export default async function Page() {
const data = await fetchData() // サーバー側で実行
return (
<div>
<h1>Server Component</h1>
<ClientButton data={data} />
</div>
)
}
// app/client-button.tsx - Client Component
'use client'
export function ClientButton({ data }) {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
{data.title}: {count}
</button>
)
}
重要な制約
Client Component内でServer Componentをimportできない
'use client'
// これはエラー
import { ServerComponent } from './server-component'
export function ClientComponent() {
return <ServerComponent />
}
childrenとして渡すことは可能
// app/layout.tsx - Server Component
export default function Layout({ children }) {
return (
<html>
<body>
<ClientProvider>
{children}
</ClientProvider>
</body>
</html>
)
}
// app/client-provider.tsx
'use client'
export function ClientProvider({ children }) {
return <div className="provider">{children}</div>
}
データフェッチパターン
パターン1: Server Componentで直接フェッチ
// app/posts/page.tsx
export default async function PostsPage() {
const posts = await db.post.findMany()
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
メリット: シンプル、バンドルサイズ削減、SEO最適 デメリット: インタラクションには不向き
パターン2: Server Componentからpropsで渡す
// app/dashboard/page.tsx
export default async function Dashboard() {
const [user, stats] = await Promise.all([
fetchUser(),
fetchStats(),
])
return (
<>
<UserProfile user={user} />
<StatsChart data={stats} />
</>
)
}
// components/stats-chart.tsx
'use client'
export function StatsChart({ data }) {
return <Chart data={data} />
}
パターン3: Server Actionsで変更
// app/actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title')
const content = formData.get('content')
await db.post.create({
data: { title, content }
})
revalidatePath('/posts')
}
// app/posts/new/page.tsx
import { createPost } from '@/app/actions'
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" />
<textarea name="content" />
<button type="submit">作成</button>
</form>
)
}
パターン4: Streaming SSR
import { Suspense } from 'react'
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<Skeleton />}>
<SlowComponent />
</Suspense>
<Suspense fallback={<Skeleton />}>
<AnotherSlowComponent />
</Suspense>
</div>
)
}
async function SlowComponent() {
const data = await slowFetch()
return <div>{data}</div>
}
これにより、ページの一部を先に表示し、残りをストリーミングで送信できます。
キャッシング戦略
fetch APIのキャッシュ
// デフォルトでキャッシュされる
const data = await fetch('https://api.example.com/data')
// キャッシュしない
const data = await fetch('https://api.example.com/data', {
cache: 'no-store'
})
// 60秒ごとに再検証
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }
})
React cacheを使った重複排除
import { cache } from 'react'
// 同一レンダリング内で複数回呼ばれても1回だけ実行
const getUser = cache(async (id: string) => {
return await db.user.findUnique({ where: { id } })
})
export async function UserProfile({ id }) {
const user = await getUser(id)
return <div>{user.name}</div>
}
export async function UserAvatar({ id }) {
const user = await getUser(id)
return <img src={user.avatar} />
}
unstable_cacheでデータキャッシュ
import { unstable_cache } from 'next/cache'
const getCachedPosts = unstable_cache(
async () => db.post.findMany(),
['posts'],
{ revalidate: 3600 }
)
export async function PostsList() {
const posts = await getCachedPosts()
return <ul>{posts.map(/* ... */)}</ul>
}
パフォーマンス最適化
1. コンポーネント分割
// 悪い例: 全体がClient Component
'use client'
export default function Page() {
const [count, setCount] = useState(0)
return (
<div>
<Header />
<button onClick={() => setCount(count + 1)}>{count}</button>
<Footer />
</div>
)
}
// 良い例: 必要な部分のみClient Component
export default function Page() {
return (
<div>
<Header />
<Counter />
<Footer />
</div>
)
}
2. 並列データフェッチ
// 直列フェッチ(遅い)
export default async function Page() {
const user = await fetchUser()
const posts = await fetchPosts()
const comments = await fetchComments()
}
// 並列フェッチ(速い)
export default async function Page() {
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments(),
])
}
3. Suspenseの戦略的配置
export default function Page() {
return (
<>
<Header />
<Suspense fallback={<MainSkeleton />}>
<MainContent />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</>
)
}
4. 動的インポート
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('./heavy-chart'), {
loading: () => <p>Loading...</p>,
ssr: false,
})
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<HeavyChart />
</div>
)
}
実践的な設計パターン
パターン1: レイアウトとページの分離
// app/layout.tsx - 共通レイアウト
export default function RootLayout({ children }) {
return (
<html>
<body>
<Navigation />
<main>{children}</main>
<Footer />
</body>
</html>
)
}
// app/dashboard/layout.tsx - ダッシュボード用レイアウト
export default async function DashboardLayout({ children }) {
const user = await getCurrentUser()
return (
<div className="dashboard">
<Sidebar user={user} />
<div className="content">{children}</div>
</div>
)
}
パターン2: Server Actionsでの楽観的更新
'use client'
import { useOptimistic } from 'react'
import { likePost } from './actions'
export function LikeButton({ postId, initialLikes }) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(state) => state + 1
)
return (
<form action={async () => {
addOptimisticLike()
await likePost(postId)
}}>
<button type="submit">
❤️ {optimisticLikes}
</button>
</form>
)
}
まとめ
React Server Componentsは、以下の利点をもたらします。
- バンドルサイズの大幅削減
- サーバー側リソースへの直接アクセス
- 自動的なコード分割
- ストリーミングによる段階的レンダリング
- SEOとパフォーマンスの両立
ただし、Server ComponentとClient Componentの境界設計、データフェッチ戦略、キャッシング設計を適切に行う必要があります。
まずは小規模なページから試して、RSCの特性を理解してから本格的に導入することをお勧めします。Next.js App Routerを使えば、RSCを簡単に試すことができます。