本番環境のReact Server Components:パターンと落とし穴

本番環境のReact Server Components:実戦で鍛えられたパターン、よくあるアンチパターン、堅牢なNext.js 15アプリケーションのためのデバッグ戦略です。

本番環境におけるReact Server Componentsのパターンと落とし穴

React Server Components(RSC)はNext.js 15におけるサーバーレンダリングの仕組みを根本から変えますが、本番投入では公式ドキュメントが必ずしもカバーしていない落とし穴が見えてきます。本記事では、機能するパターン、壊れるパターン、そして本番に達する前に問題を診断する方法を解説します。

Server ComponentsとClient Components

Server Componentはサーバー上だけで実行され、ブラウザに送られるJavaScriptはゼロです。Client Component("use client"で印付け)は両側で動作します。原則として、Client Componentsはできる限り小さく、ツリーのできる限り低い位置に配置します。

サーバー・クライアント境界:boundaryパターンを理解する

もっとも頻出するRSCの落とし穴は、ServerとClient Componentsの境界に関するものです。コンポーネントが"use client"ディレクティブを持った瞬間、インポートされた子も全てClient Componentsになります。たとえディレクティブを持たない子でも例外ではありません。

ProductPage.tsx (Server Component)tsx
import { ProductDetails } from './ProductDetails'
import { AddToCartButton } from './AddToCartButton'

export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const product = await getProduct(id)

  return (
    <div>
      {/* Server Component: 直接DBにアクセス */}
      <ProductDetails product={product} />
      {/* Client Component: 隔離された対話性 */}
      <AddToCartButton productId={product.id} price={product.price} />
    </div>
  )
}
AddToCartButton.tsx (Client Component)tsx
'use client'

import { useState } from 'react'

export function AddToCartButton({ productId, price }: { productId: string; price: number }) {
  const [adding, setAdding] = useState(false)

  async function handleAdd() {
    setAdding(true)
    await fetch('/api/cart', {
      method: 'POST',
      body: JSON.stringify({ productId, quantity: 1 }),
    })
    setAdding(false)
  }

  return (
    <button onClick={handleAdd} disabled={adding}>
      {adding ? '追加中...' : `カートに追加 — $${price}`}
    </button>
  )
}

鍵となるパターン:Server ComponentからClient Componentへシリアライズ可能なpropsとしてデータを渡すことです。関数、クラス、Dateオブジェクトはこの境界を越えられません。

アンチパターン:不要なClient Componentラッパー

ありがちな誤りは、Server Componentの子をラップするClient Componentを作り、サブツリー全体をクライアント側に押し出してしまうことです。

PageWrapper.tsx — アンチパターンtsx
'use client'

import { useState } from 'react'

// すべての子コンテンツがクライアント側になる
export function PageWrapper({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState('light')
  return (
    <div className={theme}>
      <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
        テーマ切替
      </button>
      {children}
    </div>
  )
}
slotとしてのchildren

対処方法:Server Componentsをchildrenとして渡すこと(slotパターン)です。propsとして渡された子は、親がClient Componentであっても引き続きServer Componentsとして扱われます。上記のコードは、childrenがServer Componentである親から来ている限り正しく動作します。

layout.tsx (Server Component)tsx
import { PageWrapper } from './PageWrapper'
import { HeavyServerContent } from './HeavyServerContent'

export default function Layout() {
  return (
    <PageWrapper>
      {/* クライアントラッパーがあってもServer Componentのまま */}
      <HeavyServerContent />
    </PageWrapper>
  )
}

このコンポジションパターンは、重いコンテンツに対するサーバーレンダリングの利点を保ちつつ、ラッパーレベルでの対話性を可能にします。

非同期データの扱い:コンポーネント内fetchパターン

React 19とNext.js 15はServer Components内でasync/awaitを直接サポートします。このパターンは旧来のgetServerSideProps方式に比べてデータ取得をシンプルにします。

UserProfile.tsx (Server Component)tsx
import { cache } from 'react'

// 同一レンダー中の同一呼び出しを重複排除
const getUser = cache(async (userId: string) => {
  const res = await fetch(`https://api.example.com/users/${userId}`, {
    next: { revalidate: 3600 }, // 1時間キャッシュ
  })
  if (!res.ok) throw new Error('User not found')
  return res.json()
})

export default async function UserProfile({ userId }: { userId: string }) {
  const user = await getUser(userId)

  return (
    <section>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <p>登録日: {new Date(user.createdAt).toLocaleDateString('ja-JP')}</p>
    </section>
  )
}

重要なポイントが3点あります:

  • Reactのcache()は単一のサーバーレンダー中に同一呼び出しを重複排除します
  • next: { revalidate }はNext.js側のキャッシュ時間を制御します
  • 非同期Server Componentでのエラーは最も近いerror.tsxをトリガーします

シリアライズの落とし穴:境界を越えられないもの

ServerとClient Components間でやり取りされるデータはJSONシリアライズ可能でなければなりません。以下が静かなエラーやクラッシュを招く例です。

tsx
// 落とし穴: シリアライズ不能な型を渡す
// 関数 — 動作しない
<ClientComp onSubmit={async (data) => { /* server action */ }} />
// 代わりにimportしたServer Actionを使用
import { submitForm } from '@/lib/actions/form'
<ClientComp onSubmit={submitForm} />

// Dateオブジェクト — 動作しない
<ClientComp createdAt={new Date()} />
// ISO文字列 — 動作する
<ClientComp createdAt={new Date().toISOString()} />

// Map, Set, RegExp — 動作しない
<ClientComp data={new Map([['key', 'value']])} />
// プレーンオブジェクトまたは配列 — 動作する
<ClientComp data={{ key: 'value' }} />

Server Actions("use server"で印付けされた関数)は例外で、Next.jsがHTTPエンドポイントに変換するため、Client Componentにpropsとして渡せます。

React / Next.jsの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

ストリーミングとSuspense:段階的読み込みパターン

Suspenseを用いたSSRストリーミングは、HTMLをブラウザに段階的に送ります。最適なパターンは、各非同期セクションの周りに細かなSuspense境界を設けることです。

DashboardPage.tsx (Server Component)tsx
import { Suspense } from 'react'
import { RevenueChart } from './RevenueChart'
import { RecentOrders } from './RecentOrders'
import { UserStats } from './UserStats'

export default function DashboardPage() {
  return (
    <div className="grid grid-cols-2 gap-6">
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>

      <Suspense fallback={<StatsSkeleton />}>
        <UserStats />
      </Suspense>

      <Suspense fallback={<TableSkeleton />}>
        <RecentOrders />
      </Suspense>
    </div>
  )
}

各セクションは独立して読み込まれます。RevenueChartが3秒、UserStatsが200msかかる場合でも、統計はチャートを待たずに即座に表示されます。

SuspenseとSEO

Suspense境界の中身はサーバーでレンダリングされ、初期HTMLに含まれます。クローラは完全なコンテンツを認識します。ストリーミングはブラウザへの配信速度に影響するだけで、SEO上の可視性には影響しません。

本番環境のデバッグ:RSCの問題を追跡する

RSCのエラーはしばしば難解です。本番でも有効な3つの診断手法があります。

1. ハイドレーションのミスマッチを特定する

debug-hydration.tsxtsx
'use client'

import { useEffect, useState } from 'react'

export function HydrationDebug() {
  const [isClient, setIsClient] = useState(false)

  useEffect(() => {
    setIsClient(true)
  }, [])

  if (process.env.NODE_ENV !== 'development') return null

  return (
    <div style={{ position: 'fixed', bottom: 0, right: 0, padding: '4px 8px', fontSize: 12 }}>
      {isClient ? 'Client' : 'Server'}
    </div>
  )
}

2. RSCペイロードをログに記録する

Next.js 15ではnext.config.tsでRSCログを有効化します:

next.config.tstypescript
const nextConfig = {
  logging: {
    fetches: {
      fullUrl: true, // fetchの完全なURLを表示
    },
  },
}

export default nextConfig

3. ペイロードサイズを確認する

肥大化したRSCペイロード(> 128 KB)はパフォーマンスを低下させます。DevToolsでtext/x-componentコンテンツタイプのネットワークリクエストを監視することが望まれます。

高度なパターン:Server Actionsとのコンポジション

Server ActionsとServer Componentsを組み合わせると自然なCQRSパターンになります:サーバー側で読み(RSC)、actionsで書き込みです。

TodoList.tsx (Server Component)tsx
import { getTodos } from '@/lib/services/todo'
import { TodoForm } from './TodoForm'
import { deleteTodo } from '@/lib/actions/todo'

export default async function TodoList() {
  const todos = await getTodos()

  return (
    <div>
      <TodoForm />
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            {todo.title}
            <form action={deleteTodo}>
              <input type="hidden" name="id" value={todo.id} />
              <button type="submit">削除</button>
            </form>
          </li>
        ))}
      </ul>
    </div>
  )
}
actions/todo.tstsx
'use server'

import { revalidatePath } from 'next/cache'
import { TodoService } from '@/lib/services/todo'

export async function deleteTodo(formData: FormData) {
  const id = formData.get('id') as string
  await TodoService.delete(id)
  revalidatePath('/todos')
}

revalidatePathの呼び出しにより、ページ全体を再読み込みすることなく更新済みデータでServer Componentが再レンダリングされます。

これらのトピックでより深く面接対策を行うには、SharpSkillのNext.js Server ActionsモジュールおよびNext.js Data Fetchingモジュールを参照することをお勧めします。React公式ドキュメントはServer Componentsの完全な仕様を扱っています。

結論

  • Client Componentsはコンポーネントツリーの低い位置に小さく隔離して配置する
  • slotパターン(children)を用いてクライアントラッパー内のServer Componentsを保つ
  • サーバー・クライアント境界を越えるpropsのシリアライズ可能性を常に検証する
  • 独立した非同期セクションごとに細かなSuspense境界を配置する
  • 本番でRSCペイロードサイズを監視する(目標 < 128 KB)
  • 自然なCQRSパターンとしてServer Components(読み)とServer Actions(書き)を組み合わせる
  • Reactのcache()を用いて単一のサーバーレンダー内のリクエストを重複排除する

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

タグ

#react server components
#next.js 15
#rsc patterns
#production
#react 19

共有

関連記事