数週間前、私たちの本番アプリがハングし始めました。コンポーネントがランダムに読み込まれなくなったのです。ユーザーの画面ローディングスピナーの前で固まってしまいました。40時間デバッグした末に、私たちは気づきました。React Server Components(RSC)が問題だったのです。
イントロダクション:理想 vs. 現実
当初、React Server Components(RSC)は革命的であるはずでした。
Reactチームは以下を強調していました:
- ✅ パフォーマンスの向上
- ✅ バンドルサイズの削減
- ✅ 自動的なコード分割
- ✅ コンポーネントからのダイレクトなデータベースアクセス
私たちは彼らを信頼し、Next.jsアプリ全体をServer Componentsと共にApp Routerへ移行しました。
3カ月後、私たちのアプリは以下の状況に陥りました:
- 初期ロードが遅い
- デバッグがより複雑に
- 経験の浅い開発者にとって理解しにくい
- 原因不明なキャッシュ問題に悩まされている
この記事は、Reactコミュニティーが必要としている率直な会話です。マーケティングでも、誇張でもありません。React Server Componentsを使った、本番環境でのリアルな体験談です。
Part 1:React Server Componentsとは何か(シンプルバージョン)
従来モデル(Client Components)
'use client'
export default function UserProfile() {
const [user, setUser] = useState(null)
useEffect(() => {
fetch('/api/user')
.then(res => res.json())
.then(setUser)
}, [])
if (!user) return div>Loading.../div>
return div>{user.name}/div>
}
処理フロー:
- ブラウザーがJavaScriptをダウンロードする
- コンポーネントがマウントされる
useEffectが発火- APIへのフェッチリクエスト
- レスポンスを待機
- stateを更新
- 再レンダリング
結果:ユーザーに「Loading…」が1〜2秒間表示される
Server Componentモデル
import { db } from '@/lib/database'
export default async function UserProfile() {
const user = await db.user.findFirst()
return div>{user.name}/div>
}
処理フロー:
- リクエストがサーバーに到達
- コンポーネントがサーバーで実行される
- データベースクエリが実行される
- データを含んだHTMLがブラウザーに送信される
- ユーザーにコンテンツが即座に表示される
結果:ユーザーは即座にデータを閲覧できる(理論上は)
理想
Server Componentsは以下の問題の解決を目指すものでした:
- ローディング状態
- クライアントサイドのデータフェッチ
- APIルートのボイラープレート
- 巨大なJavaScriptバンドル
現実は、もっと複雑です。
Part 2:落とし穴
問題点 1:暗黙のウォーターフォール
Server Componentsで実際に起こることを見てみましょう:
export default async function Dashboard() {
const user = await getUser()
return (
div>
Header user={user} />
Stats userId={user.id} /> {}
RecentActivity userId={user.id} /> {}
/div>
)
}
async function Stats({ userId }) {
const stats = await getStats(userId)
return div>{stats.total}/div>
}
async function RecentActivity({ userId }) {
const activity = await getActivity(userId)
return div>{activity.map(...)}/div>
}
期待する動作:並列リクエスト(最大300ms)
実際の動作:シーケンシャルなウォーターフォール
getUser()– 200ms- コンポーネントがレンダリングされ、
を検出 getStats()– 300ms(ステップ1の後に開始)- コンポーネントがレンダリングされ、
を検出 getActivity()– 250ms(ステップ3の後に開始)
合計時間:750ms(並列化されていない!)
なぜこうなるのか:Reactはコンポーネントをシーケンシャルにレンダリングします。各非同期コンポーネントが、次のコンポーネントをブロックするのです。
修正
手動で並列化しなければなりません:
export default async function Dashboard() {
const [user, stats, activity] = await Promise.all([
getUser(),
getStats(),
getActivity()
])
return (
div>
Header user={user} />
Stats data={stats} /> {}
RecentActivity data={activity} /> {}
/div>
)
}
しかし、これでは以下の利点が失われます:
- コンポーネントのカプセル化
- 関心の分離
- Server Componentsの目的
問題点 2:キャッシュというブラックボックス
React 19とNext.js 14以降は、積極的なキャッシュ機構を備えています。これは良いことのように聞こえますが、本番環境で問題を起こすまでは、です。
私たちが遭遇した実際のバグ:
export default async function PostsPage() {
const posts = await db.post.findMany()
return PostList posts={posts} />
}
起こったこと:
- ユーザーが新しい投稿を作成
/postsにリダイレクトされる- 新しい投稿が表示されない
- ページをリロードしても無駄
- ブラウザーのキャッシュをクリアしても無駄
理由:Next.jsがサーバー上でデータベースのクエリ結果をキャッシュしていました。そして、そのキャッシュを無効化(invalidate)していなかったのです。
解決策:
export const revalidate = 0
export default async function PostsPage() {
const posts = await db.post.findMany()
return PostList posts={posts} />
}
しかしながら:
- パフォーマンス上の利点が失われる
- ページロードごとにデータベースにアクセスが走る
- Client Componentsを使っていた頃のパフォーマンスに逆戻り
より深刻な問題:何がキャッシュされているのか確認できません。キャッシュインスペクターのようなものはありません。推測するしかないのです。
問題点 3:クライアントとサーバーの境界が分かりにくい
これが私たちのチームにとって最大の問題です:
'use client'
import { ServerComponent } from './ServerComponent'
export default function ClientComponent() {
const [count, setCount] = useState(0)
return (
div>
button onClick={() => setCount(count + 1)}>
Count: {count}
/button>
ServerComponent /> {}
/div>
)
}
エラー:「Server ComponentをClient Componentにインポートしています」
理由:ひとたび'use client'を使うと、その配下は全てClient Componentでなければならないからです。
修正:
'use client'
export default function ClientComponent({ children }) {
const [count, setCount] = useState(0)
return (
div>
button onClick={() => setCount(count + 1)}>
Count: {count}
/button>
{children}
/div>
)
}
ClientComponent>
ServerComponent />
/ClientComponent>
これは直感的でないように見えます。経験の浅い開発者は、この仕様に何週間も苦しめられています。
問題点 4:フォームの複雑性
従来のフォームハンドリング:
'use client'
export default function Form() {
async function handleSubmit(e) {
e.preventDefault()
const res = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(formData)
})
if (res.ok) router.push('/success')
}
return form onSubmit={handleSubmit}>.../form>
}
シンプルに機能し、誰もが理解できます。
Server Actions(RSC流):
'use server'
export async function submitForm(formData: FormData) {
const name = formData.get('name')
await db.user.create({ data: { name } })
revalidatePath('/users')
redirect('/success')
}
export default function Form() {
return (
form action={submitForm}>
input name="name" />
button type="submit">Submit/button>
/form>
)
}
問題点:
- エラーハンドリングが不明確:どこでエラーをキャッチすればよいのでしょう?
- ローディング状態:どうやってスピナーを表示するのでしょう?
- バリデーション:クライアントサイドのバリデーションにはClient Componentが必要
「解決」にはuseFormStatusが必要です:
'use client'
import { useFormStatus } from 'react-dom'
import { submitForm } from './actions'
function SubmitButton() {
const { pending } = useFormStatus()
return (
button disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
/button>
)
}
export default function Form() {
return (
form action={submitForm}>
input name="name" />
SubmitButton />
/form>
)
}
このために必要なもの:
- Server Actions用の別ファイル
- ボタン用のClient Component
- 学習すべき新しいフック
- 増えるファイルと複雑さ
以前の方法のシンプルさと比べて、どれほどのメリットがあるのでしょうか。
問題点 5:TypeScriptの型安全性が失われる
Server Componentsは、TypeScriptを巧妙なやり方で壊します:
export async function getUser() {
return await db.user.findFirst()
}
export default async function Page() {
const user = await getUser()
return UserProfile user={user} />
}
'use client'
interface Props {
user: User
}
export default function UserProfile({ user }: Props) {
return div>{user.createdAt.toISOString()}/div>
}
問題点:Server ComponentsはpropsをJSONにシリアライズします。Dateオブジェクトは文字列になってしまうのです。
TypeScriptはこれを型エラーとして検知できません。本番環境でランタイムエラーが発生します。
修正:手動でのシリアライズ
export async function getUser() {
const user = await db.user.findFirst()
return {
...user,
createdAt: user.createdAt.toISOString()
}
}
このために必要なこと:
- データベース呼び出しごとのシリアライズ関数
- サーバー用とクライアント用で別々の型定義
- 安全のためのランタイムチェック
Part 3:Server Componentsがうまく機能するケース
ただ否定ばかりしたいわけではありません。Server Componentsがうまく機能する特定のユースケースもあります。
✅ ユースケース 1:静的コンテンツサイト
export default async function BlogPost({ params }) {
const post = await getPost(params.slug)
return (
article>
h1>{post.title}/h1>
Markdown content={post.content} />
/article>
)
}
うまくいく理由:
- インタラクティビティーが不要
- コンテンツの変更がまれ
- キャッシュに最適
- SEOに強い
結論:Server Componentsが輝ける場所です。
✅ ユースケース 2:ダッシュボードのレイアウト
export default async function DashboardLayout({ children }) {
const user = await getCurrentUser()
return (
div>
Sidebar user={user} />
main>{children}/main>
/div>
)
}
うまくいく理由:
- 全てのページでユーザーデータが必要
- レイアウト部分のインタラクティビティーは最小限
- ユーザーセッションをキャッシュできる
結論:良いユースケースです。
✅ ユースケース 3:データテーブル(フィルターなし)
export default async function UsersTable() {
const users = await db.user.findMany()
return (
table>
{users.map(user => (
tr key={user.id}>
td>{user.name}/td>
td>{user.email}/td>
/tr>
))}
/table>
)
}
うまくいく理由:
- 表示専用のデータ
- クライアントサイドのstateが不要
- サーバーサイドレンダリングの方が速い
結論:適切なユースケースです。
Part 4:Server Componentsが失敗するケース
❌ アンチパターン 1:リアルタイム更新
export default async function LiveFeed() {
const posts = await getPosts()
return PostList posts={posts} />
}
問題点:更新をサブスクライブする方法がありません。WebSocketを利用するためにはClient Componentが必要です。
必要なもの:useEffectとWebSocket接続を持つClient Component。
Server Componentsはこういった場面では力を発揮できません。
❌ アンチパターン 2:複雑なフォーム
export default function MultiStepForm() {
}
問題点:フォームはクライアントサイドのstateを必要とします。Server Actionsとクライアントのstateを混ぜるのは混乱のもとです。
解決策:制御された入力を持つClient Componentを使う。
❌ アンチパターン 3:インタラクティブ性の高いUI
export default async function DataGrid() {
const data = await getData()
return Table data={data} />
}
問題点:全てのインタラクションでサーバーとのラウンドトリップが必要になります。
解決策:ローカルstateまたはTanstack Query(旧React Query)等を持つClient Component。
Part 5:Server Componentsの本当のコスト
Server Componentsの隠れたコストについて話しましょう。
コスト 1:開発者体験
Server Components以前:
- 経験の浅い開発者がチームに参加
- Reactフックを学ぶ
- クライアントサイドのデータフェッチを理解する
- 1〜2週間で価値を発揮し始める
Server Components以後:
- 経験の浅い開発者がチームに参加
- Reactフックを学ぶ
- Server Componentsを学ぶ
- クライアント/サーバーの境界ルールを学ぶ
- Server Actionsを学ぶ
- キャッシュの挙動を学ぶ
- どのパターンをいつ使うべきか学ぶ
- 1〜2カ月で生産的になる(運が良ければ)
私たちのチームの実際の統計:オンボーディング期間が2週間から6週間に増加しました。
コスト 2:デバッグの難しさ
Client Componentのバグ:
- DevToolsを開く
- コンソールでエラーを確認
- ブレークポイントを追加
- コードをステップ実行
- バグを修正
所要時間:10〜30分
Server Componentのバグ:
- エラーはターミナルに表示される(ブラウザーではない)
- ブラウザーのDevToolsが使えない
- console.log文を追加
- 問題を再現させる
- ターミナルのログを確認
- ステップ3〜6を何度も繰り返す
- 最終的にバグを発見
所要時間:1〜3時間
コスト 3:バンドルサイズ
理想:Server Components利用によるバンドルサイズ削減
現実の確認:
Server Components以前(純粋なクライアント):
- Reactバンドル:45KB
- アプリコード:120KB
- 合計:165KB
Server Components以後:
- Reactバンドル:45KB
- React Server Componentsランタイム:28KB(new!)
- アプリコード(クライアント部分):80KB
- Server Actionボイラープレート:15KB
- 合計:168KB
バンドルサイズ +3KB(1.8%)
しかし、待ってください、まだあります:
- HTMLサイズの増加(サーバーでレンダリングされたコンテンツ)
- ネットワークリクエストの増加(Server Componentツリー)
- RSCペイロードのオーバーヘッド
実際の結果:初期バンドルは減少どころかわずかに増大し、総転送データ量は増加しました。
コスト 4:パフォーマンス(という驚き)
移行前後で測定しました:
メトリクス:Time to Interactive (TTI)
Server Components以前:
- ホームページ:1.2秒
- ダッシュボード:1.8秒
- 製品ページ:1.4秒
Server Components以後:
- ホームページ:1.9秒(58%悪化!)
- ダッシュボード:2.4秒(33%悪化!)
- 製品ページ:1.1秒(21%改善)
なぜ遅くなったのか?
- サーバーレンダリングに時間がかかる
- ウォーターフォールリクエスト(問題点 1を参照)
- APIレスポンスのクライアントサイドキャッシュがない
なぜ製品ページは速くなったのか?
- シンプルでデータ中心のページ
- インタラクティビティーがない
- RSCの完璧なユースケース
学び:Server Componentsは自動的に速くなるわけではありません。
Part 6:コミュニケーションの問題
私が最もフラストレーションを感じるのは、Reactチームがこれらの問題を認識していたことです。
私の率直な印象:
- ウォーターフォール問題:Reactのドキュメントに記載があるがやや伝わりづらい
- キャッシュ問題:「より良いdevtoolsを開発中です」(2年間ずっと)
- TypeScript問題:「これは期待される動作です」
- デバッグの難しさ:「console.logを使ってください」(本気で?)
コミュニティーは、ドキュメントからではなく、本番環境でのつらい経験を通じてこれらの問題を発見しました。
以下と比較してみてください:
- Svelte:優れたドキュメント、明確な制限事項
- Vue:トレードオフについて正直
- Solid:学習曲線について率直
Part 7:では、実際どうすべきか?
戦略 1:選択的導入(推奨)
Server Componentsを使うケース:
- 静的コンテンツ
- シンプルなデータ表示
- レイアウトコンポーネント
- SEOが重要なページ
Client Componentsを使うケース:
- バリデーション付きのフォーム
- リアルタイム機能
- インタラクティブなUI
- 複雑なstate管理
構成例:
app/
(marketing)/ # Server Components
page.tsx
about/page.tsx
(dashboard)/ # Mixed
layout.tsx # Server Component
page.tsx # Client Component (interactive)
(blog)/ # Server Components
[slug]/page.tsx
戦略 2:ハイブリッドレンダリング
export default async function ProductPage({ params }) {
const product = await getProduct(params.id)
return (
div>
h1>{product.name}/h1>
p>{product.description}/p>
{}
AddToCartButton productId={product.id} />
Reviews productId={product.id} />
/div>
)
}
'use client'
function AddToCartButton({ productId }) {
const [loading, setLoading] = useState(false)
async function handleClick() {
setLoading(true)
await addToCart(productId)
setLoading(false)
}
return button onClick={handleClick}>Add to Cart/button>
}
これがうまくいく理由:
- サーバーが静的コンテンツをレンダリング
- クライアントがインタラクティビティーを処理
- 関心の分離が明確
戦略 3:待つ(議論の余地はあるが、妥当)
新しいプロジェクトを始める場合:
以下に該当するなら、まだServer Componentsを使わないことを検討してください:
- 小規模なチームである
- 迅速なイテレーションが必要
- アプリのインタラクティブ性が高い
- 開発者体験を重視する
代わりに以下を使い続けてください:
- Pages Router(Next.js 12までの標準)
- Tanstack Queryを使ったClient Components
- 従来のAPIルート
理由:これらのパターンは:
- ドキュメントが整備されている
- よく理解されている
- 実戦でテスト済み
- デバッグが容易
Server Componentsはいずれ成熟します。エコシステムも改善されるでしょう。移行は後からでもできます。
Part 8:移行ガイド(どうしても移行する場合)
ステップ 1:アプリの棚卸し
全てのページを分類します:
✅ Good for RSC:
- Marketing pages
- Blog posts
- Documentation
- Static dashboards
⚠️ Maybe:
- User profiles
- Product listings
- Search results
❌ Bad for RSC:
- Real-time chat
- Complex forms
- Canvas/drawing apps
- Admin panels with lots of interactivity
ステップ 2:小さく始める
全てを書き換えないでください。1種類のページタイプを選びます:
export default async function BlogPost({ params }) {
const post = await getPost(params.slug)
return Article post={post} />
}
まずはシンプルなページでパターンを学びましょう。
ステップ 3:徐々にインタラクティビティーを追加する
export default async function BlogPost({ params }) {
const post = await getPost(params.slug)
return (
article>
h1>{post.title}/h1>
Content>{post.content}/Content>
{}
LikeButton postId={post.id} />
Comments postId={post.id} />
/article>
)
}
Server Componentsは、データフェッチと静的コンテンツに集中させましょう。
ステップ 4:ウォーターフォールに注意する
React DevToolsのProfilerを使いましょう:
ServerComponent1 />
ServerComponent2 /> {}
ServerComponent3 /> {}
const [data1, data2, data3] = await Promise.all([
getData1(),
getData2(),
getData3()
])
ステップ 5:適切なエラー境界(Error Boundary)を設定する
'use client'
export default function Error({ error, reset }) {
return (
div>
h2>Something went wrong!/h2>
p>{error.message}/p>
button onClick={reset}>Try again/button>
/div>
)
}
Server Componentsは本番環境で失敗する可能性があるため、エラー境界が必要です。
Part 9:代替案
代替案 1:Client Components + Tanstack Queryを使い続ける
'use client'
import { useQuery } from '@tanstack/react-query'
export default function ProductPage({ params }) {
const { data: product, isLoading } = useQuery({
queryKey: ['product', params.id],
queryFn: () => fetch(`/api/products/${params.id}`).then(r => r.json())
})
if (isLoading) return Skeleton />
return ProductDetails product={product} />
}
メリット:
- よく理解されたパターン
- 優れたDX
- 強力なキャッシュ
- 容易なデバッグ
デメリット:
- クライアントサイドのローディング状態
- 大きめの初期バンドル
- SEOには追加作業が必要
結論:多くのアプリにとって、いまだに素晴らしい選択肢です。
代替案 2:Remixに移行する
Remixには Next.js より前から(loader を通じて)Server Components に類似した仕組みがありました:
export async function loader({ params }) {
return json(await getProduct(params.id))
}
export default function Product() {
const product = useLoaderData()
return ProductDetails product={product} />
}
メリット:
- よりシンプルなメンタルモデル
- より整備されたドキュメント
- 明確なデータローディングパターン
- 優れたエラーハンドリング
デメリット:
- 異なるフレームワーク
- 移行コスト
結論:新規プロジェクトでは検討する価値があります。
代替案 3:Astroとアイランドアーキテクチャ
---
// src/pages/product/[id].astro
const product = await getProduct(Astro.params.id)
---
{product.description}
メリット:
- デフォルトで静的
- オプトインでインタラクティブにできる
- 優れたパフォーマンス
- シンプルなメンタルモデル
デメリット:
- 純粋なReactではない
- エコシステムが小さい
結論:コンテンツ中心のサイトには最適です。
Part 10:未来(これから)
Reactチームのロードマップ
最近のRFCや議論から:
- より良いDevTools – 「近日公開」(2年間聞き続けていますが)
- キャッシュの改善 – よりきめ細かな制御
- ストリーミングの改善 – Suspenseとのより良い統合
- TypeScriptサポート – Server Componentsの型サポート改善
私たちが本当に必要としているもの
- Server Componentsを使うべきでない時についての明確なドキュメント
- 実際のベンチマークを伴うパフォーマンスガイドライン
- RSCを安全に導入するための移行ツール
- 実際に機能するデバッグツール
- 制限についての正直なコミュニケーション
結論
React Server Componentsは銀の弾丸(決め手)ではありません。特定のユースケース、重大な複雑さ、そして現実的なトレードオフを伴うツールです。
現実:
- 一部のアプリには適しているが、他のアプリには適していない
- メンタルモデルの大幅な転換が必要
- ドキュメントが不十分
- 本番環境での問題が頻発している
- 学習曲線が険しい
私の率直なお勧め:
Server Componentsを使うべきケース:
- ✅ コンテンツ中心のサイトを構築している
- ✅ 複雑さを扱えるシニアチームがいる
- ✅ アーリーアダプターであることをいとわない
- ✅ 学習に時間を投資できる
Server Componentsを使うべきでないケース:
- ❌ アプリのインタラクティブ性が高い
- ❌ 経験の浅い開発者が多い
- ❌ 迅速な開発が必要
- ❌ 安定性が最重要
Reactコミュニティーは、以下について率直に話し合う必要があります:
- RSCが助けになる時 vs ならない時
- 本当のDXコスト
- 実際のパフォーマンスへの影響
- ドキュメントの不備
Server ComponentsはReactの未来です。しかし、一部のアプリケーションにとってはまだ先の話です。
賢明な選択を。
クイック意思決定フレームワーク
自問してみてください:
①アプリの何%がインタラクティブですか?
- 30%未満:Server Componentsを検討
- 30〜70%:ハイブリッドアプローチを使用
- 70%超:Client Componentsを堅持
②チームの経験レベルは?
- 全員シニア:問題なし
- 混合:慎重に進める
- ほぼ経験が浅い開発者:待つ
③タイムラインは?
- 学習プロジェクト:実験する
- 厳しいデッドライン:避ける
- 長期的な投資:検討の余地あり
④優先事項は?
- パフォーマンス:まず測定する
- DX:待つ方がよいかも
- SEO:良いユースケース
- 複雑さ:避ける