サーバーコンポーネントとクライアントコンポーネントの構成パターン
Reactアプリケーションを構築する際、アプリケーションのどの部分をサーバーまたはクライアントでレンダリングするかを考慮する必要があります。このページでは、サーバーコンポーネントとクライアントコンポーネントを使用する際の推奨構成パターンについて説明します。
サーバーコンポーネントとクライアントコンポーネントの使い分け
サーバーコンポーネントとクライアントコンポーネントの異なるユースケースを簡単にまとめました:
必要な処理 | サーバーコンポーネント | クライアントコンポーネント |
---|---|---|
データ取得 | ||
バックエンドリソースへの直接アクセス | ||
機密情報(アクセストークン、APIキーなど)をサーバー上に保持 | ||
大規模な依存関係をサーバー上に保持/クライアントサイドJavaScriptの削減 | ||
インタラクティブ性とイベントリスナーの追加(onClick() 、onChange() など) | ||
ステートとライフサイクルエフェクトの使用(useState() 、useReducer() 、useEffect() など) | ||
ブラウザ専用APIの使用 | ||
ステート、エフェクト、またはブラウザ専用APIに依存するカスタムフックの使用 | ||
Reactクラスコンポーネントの使用 |
サーバーコンポーネントのパターン
クライアントサイドレンダリングを選択する前に、データの取得やデータベース・バックエンドサービスへのアクセスなど、サーバー上で処理したい場合があります。
サーバーコンポーネントを使用する際の一般的なパターンをいくつか紹介します:
コンポーネント間でのデータ共有
サーバー上でデータを取得する場合、異なるコンポーネント間でデータを共有する必要がある場合があります。例えば、同じデータに依存するレイアウトとページがある場合などです。
React Context(サーバーでは利用不可)を使用したり、データをpropsとして渡したりする代わりに、fetch
またはReactのcache
関数を使用して、必要なコンポーネントで同じデータを取得できます。Reactはfetch
を拡張してデータリクエストを自動的にメモ化し、fetch
が利用できない場合にはcache
関数を使用できます。
Reactのメモ化について詳しく学びましょう。
サーバー専用コードをクライアント環境から除外する
JavaScriptモジュールはサーバーコンポーネントとクライアントコンポーネントの間で共有できるため、サーバー上でのみ実行されることを意図したコードがクライアントに漏れる可能性があります。
例えば、次のデータ取得関数を考えてみましょう:
export async function getData() {
const res = await fetch('https://external-service.com/data', {
headers: {
authorization: process.env.API_KEY,
},
})
return res.json()
}
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_KEY
にはNEXT_PUBLIC
プレフィックスが付いていないため、これはサーバー上でのみアクセス可能なプライベート変数です。Next.jsはプライベートな環境変数がクライアントに漏れるのを防ぐため、空の文字列で置き換えます。
その結果、getData()
はクライアントでインポートして実行できますが、期待通りには動作しません。また、変数を公開すればクライアントで動作するようになりますが、機密情報をクライアントに公開したくない場合もあるでしょう。
このようなサーバーコードの意図しないクライアント使用を防ぐため、server-only
パッケージを使用して、他の開発者が誤ってこれらのモジュールをクライアントコンポーネントにインポートした場合にビルド時エラーを発生させることができます。
server-only
を使用するには、まずパッケージをインストールします:
npm install server-only
次に、サーバー専用コードを含むモジュールにパッケージをインポートします:
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の新機能であるため、エコシステム内のサードパーティパッケージやプロバイダーは、useState
、useEffect
、createContext
などのクライアント専用機能を使用するコンポーネントに"use client"
ディレクティブを追加し始めたところです。
現在、npm
パッケージの多くのコンポーネントは、クライアント専用機能を使用していても、まだこのディレクティブを持っていません。これらのサードパーティコンポーネントは、"use client"
ディレクティブを持つクライアントコンポーネント内では期待通りに動作しますが、サーバーコンポーネント内では動作しません。
例えば、仮想的なacme-carousel
パッケージをインストールし、<Carousel />
コンポーネントがあるとします。このコンポーネントはuseState
を使用していますが、まだ"use client"
ディレクティブを持っていません。
クライアントコンポーネント内で<Carousel />
を使用すると、期待通りに動作します:
'use client'
import { useState } from 'react'
import { Carousel } from 'acme-carousel'
export default function Gallery() {
let [isOpen, setIsOpen] = useState(false)
return (
<div>
<button onClick={() => setIsOpen(true)}>写真を表示</button>
{/* Carouselがクライアントコンポーネント内で使用されているため動作 */}
{isOpen && <Carousel />}
</div>
)
}
'use client'
import { useState } from 'react'
import { Carousel } from 'acme-carousel'
export default function Gallery() {
let [isOpen, setIsOpen] = useState(false)
return (
<div>
<button onClick={() => setIsOpen(true)}>写真を表示</button>
{/* Carouselがクライアントコンポーネント内で使用されているため動作 */}
{isOpen && <Carousel />}
</div>
)
}
しかし、サーバーコンポーネント内で直接使用しようとすると、エラーが発生します:
import { Carousel } from 'acme-carousel'
export default function Page() {
return (
<div>
<p>写真を表示</p>
{/* エラー: `useState`はサーバーコンポーネント内で使用できません */}
<Carousel />
</div>
)
}
import { Carousel } from 'acme-carousel'
export default function Page() {
return (
<div>
<p>写真を表示</p>
{/* エラー: `useState`はサーバーコンポーネント内で使用できません */}
<Carousel />
</div>
)
}
これは、Next.jsが<Carousel />
がクライアント専用機能を使用していることを認識していないためです。
この問題を解決するには、クライアント専用機能に依存するサードパーティコンポーネントを独自のクライアントコンポーネントでラップします:
'use client'
import { Carousel } from 'acme-carousel'
export default Carousel
'use client'
import { Carousel } from 'acme-carousel'
export default Carousel
これで、サーバーコンポーネント内で直接<Carousel />
を使用できます:
import Carousel from './carousel'
export default function Page() {
return (
<div>
<p>写真を表示</p>
{/* Carouselがクライアントコンポーネントであるため動作 */}
<Carousel />
</div>
)
}
import Carousel from './carousel'
export default function Page() {
return (
<div>
<p>写真を表示</p>
{/* Carouselがクライアントコンポーネントであるため動作 */}
<Carousel />
</div>
)
}
ほとんどのサードパーティコンポーネントはクライアントコンポーネント内で使用するため、ラップする必要はないでしょう。ただし、例外としてプロバイダーがあります。プロバイダーはReactのステートとコンテキストに依存し、通常アプリケーションのルートで必要とされます。以下のコンテキストプロバイダーの使用について詳しく学びましょう。
コンテキストプロバイダーの使用
コンテキストプロバイダーは、現在のテーマなどのグローバルな関心事を共有するために、通常アプリケーションのルート近くでレンダリングされます。Reactコンテキストはサーバーコンポーネントでサポートされていないため、アプリケーションのルートでコンテキストを作成しようとするとエラーが発生します:
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>
)
}
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>
)
}
この問題を解決するには、コンテキストを作成し、そのプロバイダーをクライアントコンポーネント内でレンダリングします:
'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>
}
'use client'
import { createContext } from 'react'
export const ThemeContext = createContext({})
export default function ThemeProvider({ children }) {
return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
}
これで、サーバーコンポーネントがクライアントコンポーネントとしてマークされたプロバイダーを直接レンダリングできるようになります:
import ThemeProvider from './theme-provider'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}
import ThemeProvider from './theme-provider'
export default function RootLayout({ children }) {
return (
<html>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}
プロバイダーがルートでレンダリングされると、アプリ全体のクライアントコンポーネントがこのコンテキストを利用できるようになります。
知っておくと良いこと: プロバイダーはツリーのできるだけ深い位置でレンダリングする必要があります。
ThemeProvider
が<html>
ドキュメント全体ではなく{children}
のみをラップしていることに注目してください。これにより、Next.jsがサーバーコンポーネントの静的な部分を最適化しやすくなります。
ライブラリ作者へのアドバイス
同様に、他の開発者が使用するパッケージを作成するライブラリ作者は、"use client"
ディレクティブを使用してパッケージのクライアントエントリポイントをマークできます。これにより、パッケージのユーザーはラッピング境界を作成することなく、サーバーコンポーネントに直接パッケージコンポーネントをインポートできます。
'use client'をツリーの深い位置で使用することで、インポートされたモジュールをサーバーコンポーネントのモジュールグラフの一部にすることができ、パッケージを最適化できます。
一部のバンドラーは"use client"
ディレクティブを削除する可能性があることに注意してください。"use client"
ディレクティブを含めるようにesbuildを設定する方法の例は、React Wrap BalancerおよびVercel Analyticsリポジトリで確認できます。
クライアントコンポーネント
クライアントコンポーネントをツリーの下位に移動する
クライアントJavaScriptバンドルサイズを削減するため、クライアントコンポーネントをコンポーネントツリーの下位に移動することを推奨します。
例えば、静的な要素(ロゴ、リンクなど)とステートを使用するインタラクティブな検索バーを持つレイアウトがあるとします。
レイアウト全体をクライアントコンポーネントにする代わりに、インタラクティブなロジックをクライアントコンポーネント(例: <SearchBar />
)に移動し、レイアウトはサーバーコンポーネントとして保持します。これにより、レイアウトのコンポーネントJavaScript全体をクライアントに送信する必要がなくなります。
// 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>
</>
)
}
// SearchBarはクライアントコンポーネント
import SearchBar from './searchbar'
// Logoはサーバーコンポーネント
import Logo from './logo'
// Layoutはデフォルトでサーバーコンポーネント
export default function Layout({ children }) {
return (
<>
<nav>
<Logo />
<SearchBar />
</nav>
<main>{children}</main>
</>
)
}
サーバーからクライアントコンポーネントへのpropsの受け渡し(シリアライズ)
サーバーコンポーネントでデータを取得する場合、そのデータをpropsとしてクライアントコンポーネントに渡したいことがあります。サーバーからクライアントコンポーネントに渡されるpropsは、Reactによってシリアライズ可能である必要があります。
クライアントコンポーネントがシリアライズ不可能なデータに依存している場合、サードパーティライブラリを使用してクライアントでデータを取得するか、サーバー上でRoute Handlerを使用してデータを取得できます。
サーバーコンポーネントとクライアントコンポーネントの組み合わせ
クライアントコンポーネントとサーバーコンポーネントを組み合わせる際は、UIをコンポーネントツリーとして視覚化すると理解しやすくなります。ルートレイアウト(サーバーコンポーネント)から始めて、"use client"
ディレクティブを追加することで、特定のサブツリーをクライアント側でレンダリングできます。
これらのクライアントサブツリー内では、サーバーコンポーネントをネストしたりサーバーアクションを呼び出したりできますが、いくつか注意点があります:
- リクエスト-レスポンスのライフサイクルでは、コードはサーバーからクライアントへ移動します。クライアント側でサーバーのデータやリソースにアクセスする必要がある場合、サーバーへの新たなリクエストが発生します(双方向の切り替えではありません)。
- サーバーに新しいリクエストが送信されると、クライアントコンポーネント内にネストされたものも含め、すべてのサーバーコンポーネントが最初にレンダリングされます。レンダリング結果(RSCペイロード)には、クライアントコンポーネントの位置情報が含まれます。その後、クライアント側でReactがRSCペイロードを使用して、サーバーコンポーネントとクライアントコンポーネントを単一のツリーに統合します。
- クライアントコンポーネントはサーバーコンポーネントの後にレンダリングされるため、サーバーコンポーネントをクライアントコンポーネントモジュールにインポートすることはできません(サーバーへの新たなリクエストが必要になるため)。代わりに、サーバーコンポーネントを
props
としてクライアントコンポーネントに渡すことができます。以下の非推奨パターンと推奨パターンセクションを参照してください。
非推奨パターン: サーバーコンポーネントのクライアントコンポーネントへのインポート
以下のパターンはサポートされていません。サーバーコンポーネントをクライアントコンポーネントにインポートすることはできません:
'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 />
</>
)
}
'use client'
// サーバーコンポーネントをクライアントコンポーネントにインポートできません
import ServerComponent from './Server-Component'
export default function ClientComponent({ children }) {
const [count, setCount] = useState(0)
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
<ServerComponent />
</>
)
}
推奨パターン: サーバーコンポーネントをpropsとしてクライアントコンポーネントに渡す
以下のパターンはサポートされています。サーバーコンポーネントをpropsとしてクライアントコンポーネントに渡すことができます。
一般的なパターンは、Reactのchildren
propを使用してクライアントコンポーネント内に「スロット」を作成する方法です。
以下の例では、<ClientComponent>
がchildren
propを受け取ります:
'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}
</>
)
}
'use client'
import { useState } from 'react'
export default function ClientComponent({ children }) {
const [count, setCount] = useState(0)
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
{children}
</>
)
}
<ClientComponent>
は、children
が最終的にサーバーコンポーネントの結果で埋められることを認識しません。<ClientComponent>
の唯一の責務は、children
が配置される場所を決定することです。
親のサーバーコンポーネントでは、<ClientComponent>
と<ServerComponent>
の両方をインポートし、<ServerComponent>
を<ClientComponent>
の子として渡すことができます:
// このパターンは有効です:
// サーバーコンポーネントをクライアントコンポーネントの子またはpropsとして渡せます
import ClientComponent from './client-component'
import ServerComponent from './server-component'
// Next.jsのページはデフォルトでサーバーコンポーネントです
export default function Page() {
return (
<ClientComponent>
<ServerComponent />
</ClientComponent>
)
}
// このパターンは有効です:
// サーバーコンポーネントをクライアントコンポーネントの子またはpropsとして渡せます
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に限定されません。任意のpropを使用してJSXを渡すことができます。