React Server Componentsの本番運用上の課題について | POSTD

数週間前、私たちの本番アプリがハングし始めました。コンポーネントがランダムに読み込まれなくなったのです。ユーザーの画面ローディングスピナーの前で固まってしまいました。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>
}

処理フロー

  1. ブラウザーがJavaScriptをダウンロードする
  2. コンポーネントがマウントされる
  3. useEffectが発火
  4. APIへのフェッチリクエスト
  5. レスポンスを待機
  6. stateを更新
  7. 再レンダリング

結果:ユーザーに「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>
}

処理フロー

  1. リクエストがサーバーに到達
  2. コンポーネントがサーバーで実行される
  3. データベースクエリが実行される
  4. データを含んだHTMLがブラウザーに送信される
  5. ユーザーにコンテンツが即座に表示される

結果:ユーザーは即座にデータを閲覧できる(理論上は)

理想

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)

実際の動作:シーケンシャルなウォーターフォール

  1. getUser() – 200ms
  2. コンポーネントがレンダリングされ、を検出
  3. getStats() – 300ms(ステップ1の後に開始)
  4. コンポーネントがレンダリングされ、を検出
  5. 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} />
}

起こったこと

  1. ユーザーが新しい投稿を作成
  2. /postsにリダイレクトされる
  3. 新しい投稿が表示されない
  4. ページをリロードしても無駄
  5. ブラウザーのキャッシュをクリアしても無駄

理由: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>
  )
}

問題点

  1. エラーハンドリングが不明確:どこでエラーをキャッチすればよいのでしょう?
  2. ローディング状態:どうやってスピナーを表示するのでしょう?
  3. バリデーション:クライアントサイドのバリデーションには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のバグ

  1. DevToolsを開く
  2. コンソールでエラーを確認
  3. ブレークポイントを追加
  4. コードをステップ実行
  5. バグを修正

所要時間:10〜30分

Server Componentのバグ

  1. エラーはターミナルに表示される(ブラウザーではない)
  2. ブラウザーのDevToolsが使えない
  3. console.log文を追加
  4. 問題を再現させる
  5. ターミナルのログを確認
  6. ステップ3〜6を何度も繰り返す
  7. 最終的にバグを発見

所要時間: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チームがこれらの問題を認識していたことです

私の率直な印象:

  1. ウォーターフォール問題:Reactのドキュメントに記載があるがやや伝わりづらい
  2. キャッシュ問題:「より良いdevtoolsを開発中です」(2年間ずっと)
  3. TypeScript問題:「これは期待される動作です」
  4. デバッグの難しさ:「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や議論から:

  1. より良いDevTools – 「近日公開」(2年間聞き続けていますが)
  2. キャッシュの改善 – よりきめ細かな制御
  3. ストリーミングの改善 – Suspenseとのより良い統合
  4. TypeScriptサポート – Server Componentsの型サポート改善

私たちが本当に必要としているもの

  1. Server Componentsを使うべきでない時についての明確なドキュメント
  2. 実際のベンチマークを伴うパフォーマンスガイドライン
  3. RSCを安全に導入するための移行ツール
  4. 実際に機能するデバッグツール
  5. 制限についての正直なコミュニケーション

結論

React Server Componentsは銀の弾丸(決め手)ではありません。特定のユースケース、重大な複雑さ、そして現実的なトレードオフを伴うツールです。

現実

  • 一部のアプリには適しているが、他のアプリには適していない
  • メンタルモデルの大幅な転換が必要
  • ドキュメントが不十分
  • 本番環境での問題が頻発している
  • 学習曲線が険しい

私の率直なお勧め

Server Componentsを使うべきケース

  • ✅ コンテンツ中心のサイトを構築している
  • ✅ 複雑さを扱えるシニアチームがいる
  • ✅ アーリーアダプターであることをいとわない
  • ✅ 学習に時間を投資できる

Server Componentsを使うべきでないケース

  • ❌ アプリのインタラクティブ性が高い
  • ❌ 経験の浅い開発者が多い
  • ❌ 迅速な開発が必要
  • ❌ 安定性が最重要

Reactコミュニティーは、以下について率直に話し合う必要があります

  • RSCが助けになる時 vs ならない時
  • 本当のDXコスト
  • 実際のパフォーマンスへの影響
  • ドキュメントの不備

Server ComponentsはReactの未来です。しかし、一部のアプリケーションにとってはまだ先の話です。
賢明な選択を。


クイック意思決定フレームワーク

自問してみてください

①アプリの何%がインタラクティブですか?

  • 30%未満:Server Componentsを検討
  • 30〜70%:ハイブリッドアプローチを使用
  • 70%超:Client Componentsを堅持

②チームの経験レベルは?

  • 全員シニア:問題なし
  • 混合:慎重に進める
  • ほぼ経験が浅い開発者:待つ

③タイムラインは?

  • 学習プロジェクト:実験する
  • 厳しいデッドライン:避ける
  • 長期的な投資:検討の余地あり

④優先事項は?

  • パフォーマンス:まず測定する
  • DX:待つ方がよいかも
  • SEO:良いユースケース
  • 複雑さ:避ける

リソース




元の記事を確認する

関連記事