「useStateやuseEffectがServer Componentで使えない…」「Client ComponentからServer Componentをインポートしたらエラーになった…」
Next.js 13以降のApp Routerで開発していると、React Server Components (RSC) とClient ComponentsのServer/Client境界線に直面し、戸惑うことは少なくありません。この境界線を正しく理解し、適切にコンポーネントを使い分けることは、Next.jsアプリケーションのパフォーマンスと保守性を最大化するために不可欠です。
この記事では、Next.js App RouterにおけるRSCとClient Componentsの基本的な違いから、Server/Client境界線の設計と実装、そしてデータフェッチのベストプラクティスまでを具体的なコード例を交えて解説します。読み終える頃には、あなたのNext.jsアプリケーションがより高速に、そして効率的に動作するためのRSC活用術が身についているでしょう。
Next.js App RouterにおけるReact Server ComponentsとClient Componentsの基礎
このセクションでは、Next.js App Routerの中核をなすReact Server Components (RSC) とClient Componentsの基本的な概念と役割について解説します。それぞれのコンポーネントがどこで実行され、どのような機能を持つのかを理解することが、適切なServer/Client境界線設計の第一歩です。
React Server Components (RSC) とは
React Server Componentsは、その名の通りサーバー上で排他的に実行されるReactコンポーネントです。Next.js App Routerでは、明示的に"use client"ディレクティブを記述しない限り、すべてのコンポーネントがデフォルトでServer Componentとして扱われます。
主な特徴とメリット:
- JavaScriptバンドルサイズ削減: Server ComponentsはクライアントにJavaScriptを送信しません。これにより、初期ロード時のJavaScriptバンドルサイズが大幅に削減され、ページのロードパフォーマンスが向上します。
- データフェッチの効率化: データベースへの直接アクセスやAPIキーの安全な利用など、サーバーサイドでデータをフェッチするのに最適です。追加のネットワークホップなしで、データベースクエリやサーバーサイドロジックを直接呼び出すことができます。
- SEOの向上: サーバーでレンダリングされるため、検索エンジンのクローラーがコンテンツを容易にインデックスでき、SEOに貢献します。
- セキュリティ: 秘匿性の高いAPIキーや認証情報などをクライアントに公開することなく利用できます。
Server Componentsは、主にデータフェッチ、レイアウトの準備、そして静的なコンテンツのレンダリングを担当します。
Client Components とは
一方、Client Componentsはブラウザで実行されるReactコンポーネントです。ファイルの一番上に"use client"ディレクティブを記述することで、Client Componentとして明示的にマークされます。
主な特徴とメリット:
-
インタラクティブなUI:
useStateやuseEffectといったReact Hooksを利用して、状態管理やライフサイクルに応じた処理を行えます。 -
ブラウザAPIの利用:
window、localStorage、イベントハンドラなど、ブラウザ固有のAPIにアクセスできます。 - ユーザーインタラクション: クリックイベント、フォーム入力、アニメーションなど、ユーザーとのインタラクションを伴うUIの構築に適しています。
Client Componentsは、インタラクティブ性や動的な挙動が求められるUI部分に特化して使用されるべきです。
Server/Client境界線とは
Server/Client境界線とは、コンポーネントツリーがサーバーとクライアントのどちらのモジュールグラフに属するかを決定する分割点です。Next.jsは、この境界線に基づいてコンポーネントコードがどこで実行されるか、そしてそのコードがブラウザに送信されるかどうかを判断します。
"use client"ディレクティブが、この境界線を定義する鍵となります。一度"use client"が宣言されると、そのファイルとそのファイルがインポートするすべてのモジュールはClient Componentsとして扱われます。
Next.jsでのRSCとClient Componentsの実装例
このセクションでは、Server ComponentとClient Componentがどのように記述され、互いに連携して動作するのかを具体的なコード例で示します。Next.js 14.x 環境での動作確認済みです。
サーバーでデータフェッチを行うServer Component
まずは、Server Component単体でデータフェッチを行う例を見てみましょう。このコンポーネントはデフォルトでServer Componentとして扱われるため、"use client"ディレクティブは不要です。
// app/products/page.tsx
// このコンポーネントはデフォルトでServer Componentです
// データベースアクセスを想定したモック関数
// 実際にはORM (Prismaなど) や直接DBクライアントを使用
async function getProductsFromDB() {
// 実際のデータベースクエリをここに記述
// 例: const products = await prisma.product.findMany();
// サーバーサイドでのみ実行されるため、安全にデータベースにアクセスできます
return [
{ id: '1', name: 'Product A', price: 100 },
{ id: '2', name: 'Product B', price: 200 },
];
}
// ProductCardもServer Componentとして定義
// インタラクティブな要素がなければ、Server Componentとして保持します
function ProductCard({ product }: { product: { id: string; name: string; price: number } }) {
return (
<div style={{ border: '1px solid #ccc', padding: '10px', margin: '10px' }}>
<h3>{product.name}</h3>
<p>Price: ${product.price}</p>
</div>
);
}
async function ProductsPage() {
// データベースへの直接アクセス - APIルートは不要で、効率的です!
const products = await getProductsFromDB();
return (
<div>
<h1>Our Products (Server Component)</h1>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
export default ProductsPage;
この例では、ProductsPageとProductCardはどちらもServer Componentです。ProductsPage内で直接getProductsFromDB()を呼び出し、サーバーサイドでデータをフェッチしています。これにより、クライアントへのJavaScript送信量を最小限に抑え、初期ロードのパフォーマンスを向上させています。
インタラクティブなUIを担うClient Component
次に、ユーザーインタラクションを伴うClient Componentの例です。"use client"ディレクティブがファイルの冒頭に必須です。
// components/LikeButton.tsx
"use client"; // これによりClient Componentとしてマークされます
import { useState } from 'react';
export default function LikeButton({ initialLikes }: { initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes); // useStateで状態を管理
const handleClick = () => {
setLikes(likes + 1);
// サーバーアクションを呼び出すことも可能 (Next.js 14以降)
// import { incrementLikesAction } from '@/app/actions';
// incrementLikesAction(postId);
};
return (
<button onClick={handleClick} style={{ padding: '8px 16px', cursor: 'pointer' }}>
いいね! ({likes})
</button>
);
}
LikeButtonはuseStateフックを使用し、ボタンがクリックされるたびにlikesの状態を更新します。このようなインタラクティブな挙動はClient Componentの役割です。
Server ComponentとClient Componentの組み合わせ方
最も一般的なパターンは、Server ComponentがClient Componentをレンダリングし、必要なデータをpropsとして渡す形です。このとき、Server Componentはサーバー上でレンダリングされ、Client Componentはクライアントに送られハイドレーションされます。
// app/post/[id]/page.tsx
import LikeButton from '@/components/LikeButton'; // Client Componentをインポート
// サーバーサイドでデータをフェッチする関数を想定したモック
async function fetchPostData(id: string) {
// 実際のデータフェッチロジック (DBアクセス、外部API呼び出しなど)
await new Promise(resolve => setTimeout(resolve, 500)); // 擬似的な遅延
return {
id: id,
title: `Post Title ${id}`,
content: `This is the content for post ${id}.`,
likes: 10,
};
}
async function PostPage({ params }: { params: { id: string } }) {
const post = await fetchPostData(params.id); // Server Componentでデータをフェッチ
return (
<div style={{ border: '1px solid blue', padding: '20px', margin: '20px' }}>
<h1>{post.title} (Server Component)</h1>
<p>{post.content}</p>
{/* Server ComponentがClient Componentをレンダリングし、シリアライズ可能なpropsを渡す */}
{/* Client ComponentのinitialLikesはServer Componentから渡されます */}
<LikeButton initialLikes={post.likes} />
</div>
);
}
export default PostPage;
PostPageはServer Componentであり、fetchPostDataで投稿データを取得します。その後、取得したpost.likesをLikeButtonというClient ComponentのinitialLikesプロップとして渡しています。
この構成により、投稿のタイトルや内容はサーバーでレンダリングされ、クライアントへのJavaScript送信を最小限に抑えつつ、インタラクティブな「いいね」ボタンはクライアントで動作するという理想的なServer/Client境界線が実現されます。
Server/Client境界線でよくあるハマりどころと回避策
このセクションでは、Next.js App Routerでの開発において、Server ComponentsとClient ComponentsのServer/Client境界線を誤解することで発生しやすいエラーとその回避策を具体的に解説します。
1. Server ComponentでのブラウザAPIやReact Hooksの使用
Server Componentはサーバー上で実行されるため、ブラウザ固有のAPIやクライアントサイドのReact Hooksは使用できません。
-
ハマりどころ: Server Component内で
window、localStorageなどのブラウザAPIや、useState、useEffectなどのReact Hooksを使用しようとすると、ビルド時または実行時にエラーが発生します。// 誤った例 (Server ComponentでuseStateを使用) // app/error-page.tsx // export default function Page() { // const [count, setCount] = useState(0); // エラー: React Hook "useState" cannot be called in a Server Component. // return <button onClick={() => setCount(count + 1)}>{count}</button>; // } -
回避策: インタラクティブな機能やブラウザAPIに依存するロジックは、必ず
"use client"ディレクティブを持つClient Component内に移動させます。// 正しい例 (Client ComponentでuseStateを使用) // components/Counter.tsx "use client"; import { useState } from 'react'; export default function Counter() { const [count, setCount] = useState(0); return <button onClick={() => setCount(count + 1)} style={{ padding: '8px', margin: '5px' }}>Count: {count}</button>; }
2. Client Component内でのServer Componentの直接インポート
Client Componentのコードはクライアントに送信されるため、サーバー専用のコードを含むServer Componentを直接インポートすることはできません。
-
ハマりどころ: Client Component内でServer Componentを直接インポートしてレンダリングしようとすると、
You're importing a component that needs "use client"...のようなエラーが発生します。// 誤った例 (Client ComponentがServer Componentを直接インポート) // components/MyClientComponent.tsx // "use client"; // import MyServerComponent from '@/components/MyServerComponent'; // エラーになる // export default function MyClientComponent() { // return <MyServerComponent />; // } -
回避策: Server ComponentをClient Componentの
childrenプロップとして渡すことで、視覚的にネストさせることができます。この場合、Server Componentはサーバーでレンダリングされ、Client ComponentはレンダリングされたHTMLを受け取ります。Server ComponentのJavaScriptコードがクライアントに送信されることはありません。// components/Modal.tsx (Client Component) "use client"; import { useState } from 'react'; export default function Modal({ children }: { children: React.ReactNode }) { const [isOpen, setIsOpen] = useState(false); return ( <> <button onClick={() => setIsOpen(true)} style={{ padding: '8px 16px', cursor: 'pointer' }}>モーダルを開く</button> {isOpen && ( <div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'center' }}> <div style={{ backgroundColor: 'white', padding: '20px', borderRadius: '8px', minWidth: '300px' }}> {children} {/* Server Componentがここにレンダリングされる */} <button onClick={() => setIsOpen(false)} style={{ marginTop: '15px', padding: '8px 16px', cursor: 'pointer' }}>閉じる</button> </div> </div> )} </> ); } // components/ServerContent.tsx (Server Component) // このコンポーネントはデフォルトでServer Component export default function ServerContent() { return ( <div style={{ border: '1px dashed green', padding: '10px' }}> <p>これはサーバーからレンダリングされたコンテンツです。</p> <p>データベースから取得した情報などを表示できます。</p> </div> ); } // app/page.tsx (Server Component) import Modal from '@/components/Modal'; import ServerContent from '@/components/ServerContent'; // Server Component export default function HomePage() { return ( <div style={{ padding: '20px' }}> <h1>Home Page (Server Component)</h1> <Modal> <ServerContent /> {/* Server Componentをchildrenとして渡す */} </Modal> </div> ); }
3. Server ComponentでのRoute Handlersへの冗長なネットワークリクエスト
Server Componentから同じNext.jsアプリケーション内のRoute Handler (app/api/.../route.ts) に対してfetchを行うと、不要なネットワークホップが発生しパフォーマンスが低下します。
-
ハマりどころ: Server ComponentもRoute Handlerもサーバー上で実行されるため、Server ComponentからRoute Handlerを
fetchすると、サーバー内でHTTPリクエストが完結するため、無駄なオーバーヘッドが生じます。// 誤った例 (Server ComponentからRoute Handlerをfetch) // app/wrong-fetch/page.tsx // export default async function Page() { // // 開発環境ではlocalhostへのfetchは動作するが、本番環境では外部URLとして扱われ、 // // サーバー内部のAPIを呼び出すために余計なネットワークリクエストが発生する。 // let res = await fetch('http://localhost:3000/api/data'); // let data = await res.json(); // return <h1>{JSON.stringify(data)}</h1>; // } -
回避策: Route Handler内に記述する予定だったロジック(外部API呼び出しやデータベースクエリなど)を直接Server Component内で呼び出すようにします。共通のロジックはヘルパー関数として抽出し、Server ComponentとRoute Handlerの両方からインポートして使用できます。
// lib/data.ts (共通のデータフェッチロジック) export async function getSomeData() { // データベースアクセスや外部API呼び出しなど await new Promise(resolve => setTimeout(resolve, 300)); // 擬似的な遅延 return { message: 'Data fetched directly on the server!' }; } // app/correct-fetch/page.tsx import { getSomeData } from '@/lib/data'; export default async function Page() { let data = await getSomeData(); // async関数を直接呼び出すことで、不要なネットワークリクエストを回避 return ( <div style={{ padding: '20px' }}> <h1>Correct Data Fetching (Server Component)</h1> <p>{JSON.stringify(data)}</p> </div> ); } // app/api/data/route.ts (Route Handlerも同じロジックを共有できる) // import { getSomeData } from '@/lib/data'; // export async function GET() { // const data = await getSomeData(); // return Response.json(data); // }
Server/Client境界線設計のベストプラクティス
Next.js App Routerで最高のパフォーマンスと保守性を実現するためには、Server/Client境界線を意識した設計が重要です。ここでは、いくつかのベストプラクティスを紹介します。
デフォルトはServer Componentを徹底する
Next.js App Routerの設計思想は「デフォルトはServer Component」です。これは、可能な限りServer Componentを使用し、インタラクティブ性、状態管理、またはブラウザAPIが必要な場合にのみ"use client"を追加するという意味です。これにより、JavaScriptバンドルサイズを最小限に抑え、初期ロードパフォーマンスを最大化できます。
データフェッチは利用箇所に近い場所で
Server Componentsは、データフェッチロジックをコンポーネントの近くに配置できるため、コードの保守性が向上します。これにより、APIルートを介した追加のネットワークホップを避けることができ、データ取得の効率が向上します。
Client Componentsは小さく集中させる
Client Componentsはインタラクティブな部分のみを抽出し、そのサイズを小さく保つように心がけます。これにより、クライアントサイドでのハイドレーション処理のパフォーマンスが向上し、ユーザーエクスペリエンスが改善されます。
Server ComponentをClient ComponentのChildrenとして渡すパターンを積極的に利用する
前述のハマりどころ「Client Component内でのServer Componentの直接インポート」を回避するために、Server ComponentをchildrenプロップとしてClient Componentに渡すパターンは非常に強力です。このパターンにより、サーバーでレンダリングされたUIをClient Component内に視覚的にネストさせつつ、Server Componentのコードがクライアントに送信されるのを防ぐことができます。
// ClientWrapper.tsx (Client Component)
"use client";
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
// ここではインタラクティブなロジックを記述
return (
<div style={{ border: '2px dashed orange', padding: '15px' }}>
<h2>Client Wrapper (インタラクティブな部分)</h2>
{children} {/* Server Componentのコンテンツがここに挿入される */}
</div>
);
}
// Page.tsx (Server Component)
import ClientWrapper from '@/components/ClientWrapper';
import ServerContent from '@/components/ServerContent'; // Server Component
export default function Page() {
return (
<ClientWrapper>
<ServerContent /> {/* Server Contentをchildrenとして渡す */}
</ClientWrapper>
);
}
Context Providersの配置に注意する
Context ProvidersはuseStateやuseEffectに依存するため、Client Componentsでのみ利用可能です。可能な限りアプリケーションツリーの深い位置に配置し、Next.jsがServer Componentsとしてレンダリングできる範囲を広げることが重要です。例えば、特定のインタラクティブなサブツリーでのみ必要なContextは、そのサブツリーのルートClient Componentで定義します。
シリアライズ可能なPropsのみを渡す
Server ComponentsからClient Componentsにデータを渡す際は、プリミティブ型、プレーンオブジェクト、配列など、シリアライズ可能な値のみを使用します。関数、シンボル、クラスインスタンスなどは渡せません。DateやObjectIdのようなオブジェクトは、必要に応じて文字列などのシリアライズ可能な形式に変換して渡しましょう。
server-only / client-only パッケージの活用
server-onlyやclient-onlyパッケージ(npm install server-only client-only)を使用することで、特定の環境でのみ実行されるコードを強制し、誤ったインポートによるエラーを開発段階で防ぐことができます。
// lib/server-utils.ts
import "server-only"; // このファイルはサーバー上でのみインポート可能
export function getServerSecret() {
return process.env.SERVER_SECRET; // クライアントに漏洩しない
}
// lib/client-utils.ts
import "client-only"; // このファイルはクライアント上でのみインポート可能
export function getBrowserInfo() {
return window.navigator.userAgent; // ブラウザAPIに安全にアクセス
}
まとめ
本記事では、Next.js App RouterにおけるReact Server Components (RSC) とClient ComponentsのServer/Client境界線に焦点を当て、その基本的な概念から具体的な実装方法、そしてよくあるハマりどころと回避策、さらにはベストプラクティスまでを解説しました。
重要なポイントをまとめると以下の通りです。
- Server Componentsはサーバーで実行され、データフェッチや静的コンテンツのレンダリング、JavaScriptバンドルサイズ削減に貢献します。デフォルトでServer Componentです。
-
Client Componentsはブラウザで実行され、
useStateやuseEffect、ブラウザAPIを利用したインタラクティブなUIを構築します。ファイル冒頭に"use client"が必要です。 -
Server/Client境界線を正しく理解し、コンポーネントを適切に分割することが、パフォーマンスと保守性の高いNext.jsアプリケーション構築の鍵です。 - Client ComponentがServer Componentを直接インポートできない制約は、Server Componentを
childrenとして渡すパターンで回避できます。 - Server Component内でのデータフェッチは、Route Handlerへの冗長なネットワークリクエストを避け、直接ロジックを呼び出すことが効率的です。
これらの知見を活用することで、あなたのNext.jsアプリケーションはより高速に、そして効率的に動作するようになるでしょう。さらに深く学びたい方は、Next.jsの公式ドキュメント - Server and Client Componentsを参照することをお勧めします。