Menu

サーバーとクライアントのコンポジションパターン

Reactアプリケーションを構築する際、アプリケーションのどの部分をサーバーまたはクライアントでレンダリングすべきかを検討する必要があります。このページでは、サーバーとクライアントコンポーネントを使用する際の推奨されるコンポジションパターンについて説明します。

サーバーとクライアントコンポーネントをいつ使用するか?

サーバーとクライアントコンポーネントの異なるユースケースの簡単な概要は以下の通りです:

何をする必要がありますか?サーバーコンポーネントクライアントコンポーネント
データの取得
バックエンドリソースへの直接アクセス
機密情報をサーバーに保持(アクセストークン、APIキーなど)
大きな依存関係をサーバーに保持 / クライアント側のJavaScriptを削減
インタラクティブ性とイベントリスナーの追加(onClick()onChange()など)
ステートとライフサイクル効果の使用(useState()useReducer()useEffect()など)
ブラウザ専用APIの使用
ステート、効果、またはブラウザ専用APIに依存するカスタムフックの使用
Reactクラスコンポーネントの使用

サーバーコンポーネントのパターン

クライアント側のレンダリングを選択する前に、データの取得やデータベース、バックエンドサービスへのアクセスなど、サーバー上でいくつかの作業を行うことができます。

サーバーコンポーネントを操作する際の一般的なパターンは以下の通りです:

コンポーネント間でのデータ共有

サーバー上でデータを取得する際、異なるコンポーネント間でデータを共有する必要がある場合があります。例えば、同じデータに依存するレイアウトとページがあるかもしれません。

React Context(サーバー上で利用できません)を使用したり、propsでデータを渡したりする代わりに、fetchまたはReactのcache関数を使用して、同じデータを必要とするコンポーネントで同じデータを取得できます。重複リクエストを心配することなく、これが可能です。これは、ReactがデータリクエストをMemoize(キャッシュ)するようにfetchを拡張しており、fetchが利用できない場合はcache関数を使用できるためです。

このパターンの例を確認してください。

サーバー専用のコードをクライアント環境から除外する

JavaScriptモジュールはサーバーとクライアントの両方のコンポーネントモジュール間で共有できるため、サーバー上でのみ実行されることを意図していたコードがクライアントに紛れ込む可能性があります。

例えば、以下のデータ取得関数を考えてみましょう:

lib/data.ts
export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })
 
  return res.json()
}
lib/data.js
export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })
 
  return res.json()
}

一見すると、getDataはサーバーとクライアントの両方で動作するように見えます。しかし、この関数にはAPI_KEYが含まれており、サーバー上でのみ実行されることを意図しています。

環境変数API_KEYNEXT_PUBLIC接頭辞付きでない場合、これはサーバー上でのみアクセス可能なプライベート変数です。環境変数がクライアントにリークするのを防ぐために、Next.jsはプライベート環境変数を空の文字列に置き換えます。

結果として、getData()はクライアントでインポートして実行できますが、期待通りに動作しません。変数を公開すれば、クライアント上で機能しますが、機密情報をクライアントに公開したくない場合もあります。

この種のサーバーコードの意図しないクライアント使用を防ぐために、server-onlyパッケージを使用して、他の開発者が誤ってこれらのモジュールをクライアントコンポーネントにインポートした場合にビルド時エラーを発生させることができます。

server-onlyを使用するには、まずパッケージをインストールします:

Terminal
npm install server-only

次に、サーバー専用のコードを含むモジュールにパッケージをインポートします:

lib/data.js
import 'server-only'
 
export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })
 
  return res.json()
}

これで、getData()をインポートするクライアントコンポーネントは、このモジュールがサーバー上でのみ使用できることを説明するビルド時エラーを受け取ります。

対応するclient-onlyパッケージは、windowオブジェクトにアクセスするなど、クライアント専用のコードを含むモジュールにマークを付けるために使用できます。

サードパーティのパッケージとプロバイダーの使用

サーバーコンポーネントは新しいReact機能であるため、エコシステム内のサードパーティパッケージとプロバイダーは、useStateuseEffectcreateContextなどのクライアント専用の機能を使用するコンポーネントに"use client"ディレクティブを追加し始めたばかりです。

現在、npmパッケージの多くのコンポーネントは、クライアント専用の機能を使用していますが、まだディレクティブがありません。これらのサードパーティコンポーネントは、"use client"ディレクティブを持つクライアントコンポーネント内では期待通りに動作しますが、サーバーコンポーネント内では動作しません。

例えば、acme-carouselパッケージに<Carousel />コンポーネントがあり、useStateを使用していますが、まだ"use client"ディレクティブがないとします。

クライアントコンポーネント内で<Carousel />を使用すると、期待通りに動作します:

app/gallery.tsx
TypeScript
'use client'
 
import { useState } from 'react'
import { Carousel } from 'acme-carousel'
 
export default function Gallery() {
  const [isOpen, setIsOpen] = useState(false)
 
  return (
    <div>
      <button onClick={() => setIsOpen(true)}>View pictures</button>
 
      {/* クライアントコンポーネント内で使用されているため、動作します */}
      {isOpen && <Carousel />}
    </div>
  )
}

しかし、サーバーコンポーネント内で直接使用しようとすると、エラーが発生します:

app/page.tsx
TypeScript
import { Carousel } from 'acme-carousel'
 
export default function Page() {
  return (
    <div>
      <p>View pictures</p>
 
      {/* エラー:サーバーコンポーネント内では`useState`を使用できません */}
      <Carousel />
    </div>
  )
}

これは、Next.jsが<Carousel />がクライアント専用の機能を使用していることを知らないためです。

これを修正するには、クライアント専用の機能に依存するサードパーティコンポーネントを独自のクライアントコンポーネントでラップできます:

app/carousel.tsx
TypeScript
'use client'
 
import { Carousel } from 'acme-carousel'
 
export default Carousel

さて、<Carousel />をサーバーコンポーネント内で直接使用できるようになりました:

app/page.tsx
TypeScript
import Carousel from './carousel'
 
export default function Page() {
  return (
    <div>
      <p>写真を見る</p>
 
      {/*  Carouselはクライアントコンポーネントなので、動作します */}
      <Carousel />
    </div>
  )
}

サードパーティコンポーネントのほとんどは、クライアントコンポーネント内で使用されるため、ラップする必要はないと予想されます。ただし、プロバイダーは例外で、Reactの状態とコンテキストに依存し、通常アプリケーションのルート近くで必要とされます。以下のサードパーティコンテキストプロバイダーについてさらに詳しく学びましょう

コンテキストプロバイダーの使用

コンテキストプロバイダーは通常、現在のテーマなどのグローバルな懸念事項を共有するために、アプリケーションのルート近くでレンダリングされます。Reactコンテキストはサーバーコンポーネントでサポートされていないため、アプリケーションのルートでコンテキストを作成しようとするとエラーが発生します:

app/layout.tsx
TypeScript
import { createContext } from 'react'
 
//  createContextはサーバーコンポーネントでサポートされていません
export const ThemeContext = createContext({})
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
      </body>
    </html>
  )
}

これを修正するには、クライアントコンポーネント内でコンテキストを作成し、そのプロバイダーをレンダリングします:

app/theme-provider.tsx
TypeScript
'use client'
 
import { createContext } from 'react'
 
export const ThemeContext = createContext({})
 
export default function ThemeProvider({
  children,
}: {
  children: React.ReactNode
}) {
  return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
}

これでサーバーコンポーネントは、クライアントコンポーネントとしてマークされたプロバイダーを直接レンダリングできるようになります:

app/layout.tsx
TypeScript
import ThemeProvider from './theme-provider'
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}

ルートでプロバイダーをレンダリングすることで、アプリ全体の他のクライアントコンポーネントがこのコンテキストを消費できるようになります。

補足: プロバイダーはツリーの可能な限り深い場所でレンダリングする必要があります。ThemeProviderが全体の<html>ドキュメントではなく{children}のみをラップしていることに注目してください。これにより、Next.jsがサーバーコンポーネントの静的部分を最適化しやすくなります。

ライブラリ作者へのアドバイス

同様に、他の開発者が消費するパッケージを作成するライブラリ作者は、"use client"ディレクティブを使用して、パッケージのクライアントエントリーポイントをマークできます。これにより、パッケージのユーザーは、ラッピング境界を作成することなく、サーバーコンポーネントにパッケージコンポーネントを直接インポートできます。

ツリーの下位にクライアントコンポーネントを移動することで、パッケージを最適化できます。これにより、インポートされたモジュールをサーバーコンポーネントモジュールグラフの一部にできます。

一部のバンドラーが"use client"ディレクティブを除去する可能性があることに注意が必要です。esbuildに"use client"ディレクティブを含めるように設定する方法の例は、React Wrap BalancerVercel Analyticsリポジトリで確認できます。

クライアントコンポーネント

クライアントコンポーネントをツリーの下位に移動

クライアントJavaScriptバンドルのサイズを削減するために、クライアントコンポーネントをコンポーネントツリーの下位に移動することをお勧めします。

例えば、静的な要素(ロゴ、リンクなど)とステートを使用する対話的な検索バーを持つレイアウトがあるとします。

レイアウト全体をクライアントコンポーネントにするのではなく、対話的なロジックを(<SearchBar />などの)クライアントコンポーネントに移動し、レイアウトはサーバーコンポーネントのままにします。これにより、レイアウトのすべてのコンポーネントJavaScriptをクライアントに送信する必要がなくなります。

app/layout.tsx
TypeScript
// SearchBarはクライアントコンポーネント
import SearchBar from './searchbar'
// Logoはサーバーコンポーネント
import Logo from './logo'
 
// Layoutはデフォルトでサーバーコンポーネント
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <nav>
        <Logo />
        <SearchBar />
      </nav>
      <main>{children}</main>
    </>
  )
}

サーバーからクライアントコンポーネントへのpropsの受け渡し(シリアライゼーション)

サーバーコンポーネントでデータをフェッチする場合、クライアントコンポーネントにpropsとしてデータを渡したいことがあります。サーバーからクライアントコンポーネントに渡されるpropsは、Reactによってシリアライズ可能である必要があります。

クライアントコンポーネントが_シリアライズ不可能_なデータに依存している場合は、サードパーティライブラリでクライアント側でデータをフェッチするか、ルートハンドラーでサーバー側でフェッチできます。

サーバーコンポーネントとクライアントコンポーネントの入れ子

サーバーコンポーネントとクライアントコンポーネントを入れ子にする場合、UIをコンポーネントツリーとして可視化すると役立ちます。ルートレイアウト(サーバーコンポーネント)から始めて、"use client"ディレクティブを追加することで、特定のサブツリーをクライアントでレンダリングできます。

リクエスト-レスポンスのライフサイクル中、コードはサーバーからクライアントに移動します。クライアント側でサーバー上のデータやリソースにアクセスする必要がある場合、サーバーへの新しいリクエストを行うことになり、行ったり来たりすることはありません。

新しいリクエストがサーバーに送信されると、クライアントコンポーネント内にネストされたものを含め、すべてのサーバーコンポーネントが最初にレンダリングされます。レンダリングされた結果(RSCペイロード)には、クライアントコンポーネントの位置への参照が含まれます。その後、クライアント側でReactはRSCペイロードを使用して、サーバーコンポーネントとクライアントコンポーネントを1つのツリーに調整します。

  • クライアントコンポーネントはサーバーコンポーネントの後にレンダリングされるため、サーバーコンポーネントをクライアントコンポーネントモジュールにインポートすることはできません(サーバーへの新しいリクエストが必要になるため)。代わりに、サーバーコンポーネントをクライアントコンポーネントの props として渡すことができます。以下のサポートされていないパターンサポートされているパターンのセクションを参照してください。

サポートされていないパターン: サーバーコンポーネントをクライアントコンポーネントにインポートする

以下のパターンはサポートされていません。サーバーコンポーネントをクライアントコンポーネントにインポートすることはできません:

app/client-component.tsx
TypeScript
'use client'
 
// サーバーコンポーネントをクライアントコンポーネントにインポートすることはできません。
import ServerComponent from './Server-Component'
 
export default function ClientComponent({
  children,
}: {
  children: React.ReactNode
}) {
  const [count, setCount] = useState(0)
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
 
      <ServerComponent />
    </>
  )
}

サポートされているパターン: サーバーコンポーネントをpropsとしてクライアントコンポーネントに渡す

以下のパターンがサポートされています。サーバーコンポーネントをクライアントコンポーネントの prop として渡すことができます。

一般的なパターンは、React の children プロップを使用してクライアントコンポーネントに_"スロット"_を作成することです。

以下の例では、<ClientComponent>children プロップを受け取ります:

app/client-component.tsx
TypeScript
'use client'
 
import { useState } from 'react'
 
export default function ClientComponent({
  children,
}: {
  children: React.ReactNode
}) {
  const [count, setCount] = useState(0)
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      {children}
    </>
  )
}

<ClientComponent>children が最終的にサーバーコンポーネントの結果で埋められることを知りません。<ClientComponent> の唯一の責任は、children が最終的にどこに配置されるかを決めることです。

親サーバーコンポーネントでは、<ClientComponent><ServerComponent> の両方をインポートし、<ServerComponent><ClientComponent> の子として渡すことができます:

app/page.tsx
TypeScript
// このパターンは機能します:
// サーバーコンポーネントをクライアントコンポーネントの子または prop として渡すことができます。
import ClientComponent from './client-component'
import ServerComponent from './server-component'
 
// Next.jsのページはデフォルトでサーバーコンポーネントです
export default function Page() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  )
}

このアプローチにより、<ClientComponent><ServerComponent> が分離され、独立してレンダリングできます。この場合、子の <ServerComponent> は、<ClientComponent> がクライアントでレンダリングされる前に、サーバー上でレンダリングできます。

補足:

  • "コンテンツを上に持ち上げる"パターンは、親コンポーネントが再レンダリングされたときに、ネストされた子コンポーネントの再レンダリングを避けるために使用されてきました。
  • children プロップに限定されるものではありません。任意の prop を使用してJSXを渡すことができます。