Next.jsにおけるServer ComponentとClient Component

1. 概要

Next.js App Routerはデフォルトでcomponentをサーバー側でrenderします。これは従来のPages Routerの「すべてクライアント側」というモデルとは大きく異なります。この記事は、Server ComponentとClient Componentの違い、いつ"use client"を使う必要があるのか、そして2つのcomponent typeをレンダリングツリー内で組み合わせてエラーを回避する方法を理解するのに役立ちます。最後には、componentをサーバー側に置くべきかクライアント側に置くべきかを自分で判断でき、その境界を越えてデータを正しく渡す方法を知ることができます。

2. Server Componentとは

Server Componentは、App Router(app/フォルダ)におけるデフォルトのcomponent typeです。サーバー側で完全に実行され、それ自体のJavaScriptをブラウザに送信しません。

// app/products/page.tsx
async function getProducts() {
  const res = await fetch("https://api.shop.local/products", { cache: "no-store" });
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <ul>
      {products.map((p: { id: number; name: string }) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

注意すべき点がいくつかあります:

  • ProductsPageはasync functionです。Server Componentは直接awaitすることができ、データをfetchするためにuseEffectやuseStateは必要ありません。
  • fetchコマンドはサーバー側で実行され、APIキーや機密ロジックはブラウザに公開されることはありません。
  • ファイルの先頭に"use client"行がないため、Next.jsはこれがServer Componentであることを自動的に認識します。

2.1. Server Componentを優先すべき理由

Server Componentはクライアント側に送信されるJavaScript bundleを削減するのに役立ちます。component自体(およびそのimport、クライアント化されていない場合)がブラウザ側のbundleに含まれないからです。また、APIルートを通じて公開することなく、database、ファイルシステム、または秘密の環境変数に直接アクセスできます。

Request → Server render (fetch, DB query) → HTML + RSC payload → Browser

ブラウザはレンダリング済みのHTMLとcomponent treeを記述するpayload(RSC payload)のみを受け取り、Server ComponentのJavaScriptソースコードは受け取りません。

3. Client Componentとは

Client Componentはブラウザで実行する必要があるcomponentです。state、effect、event handler、またはlocalStorageやwindowなどのWeb APIを使用する場合に使います。ファイルの先頭の"use client"directiveで宣言します。

// app/components/LikeButton.tsx
"use client";

import { useState } from "react";

export default function LikeButton() {
  const [liked, setLiked] = useState(false);

  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? "❤️ 気に入った" : "🤍 気に入る"}
    </button>
  );
}

説明:

  • "use client"はファイルの最初の行(importの前)である必要があり、境界をマークします。このファイルからimportされるすべてはクライアント側用にbundleされます。
  • useStateはClient Component内でのみ機能します。re-renderを管理するためにブラウザで実行されるJavaScriptが必要だからです。
  • onClickはevent handlerです。Client Componentが必要な確実な兆候です。

注意: "use client"は、componentがのみクライアント側で実行されることを意味しません。Next.jsは引き続きサーバー側で最初にrender(SSR)して高速HTMLを取得し、その後ブラウザで「hydrate」してcomponentをインタラクティブにします。

4. Client Componentをいつ必ず使う必要があるか

"use client"を追加する必要のあるケースがいくつかあります。そうしないと、buildまたはruntimeエラーが発生します:

Error: useState can not be used in Server Components.

これらのケースには以下が含まれます:

  • React hookを使用: useState、useEffect、useReducer、useContext…
  • Event handlerをアタッチ: onClick、onChange、onSubmit…
  • ブラウザにのみ存在するWeb APIを使用: window、document、localStorage。
  • 上記を含むcustom hookを使用する。
  • 上記に依存するクライアント専用ライブラリを使用する(例えばwindowに依存するcharting library)。

逆に、componentが単にpropsを受け取りJSXを純粋にrenderするだけで、hookなし、eventなし—他のClient Componentとネストされた深い場所にあっても、Server Componentのままにしておきます。

5. ServerとClient Componentを組み合わせる

このモデルの素晴らしい点は、直接importするのではなく、children(composition)を通じてServer ComponentをClient Componentに**ネストできることです。

// app/components/Modal.tsx
"use client";

import { useState } from "react";

export default function Modal({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button onClick={() => setOpen(true)}>詳細を開く</button>
      {open && <div className="modal">{children}</div>}
    </>
  );
}
// app/orders/[id]/page.tsx
import Modal from "@/app/components/Modal";

async function getOrder(id: string) {
  const res = await fetch(`https://api.shop.local/orders/${id}`);
  return res.json();
}

export default async function OrderPage({ params }: { params: { id: string } }) {
  const order = await getOrder(params.id);

  return (
    <Modal>
      {/* OrderDetailは引き続きServer Componentで、childrenとして「渡されている」だけです */}
      <p>注文番号: {order.id}</p>
      <p>合計金額: {order.total.toLocaleString("ja-JP")} 円</p>
    </Modal>
  );
}

メカニズムの説明:

  • Modalは開閉状態を管理するClient Componentです。
  • 内部のコンテンツ(<p>注文番号...)はServer ComponentであるOrderPageでrenderされ、その後childrenとしてModalに渡されます。
  • Next.jsはこのchildren部分をまずサーバー側でrenderし、hydrate時にClient Componentの{children}位置に結果を「プラグイン」します。このため、OrderService.getOrderはクライアント化される必要はありません。

注意: async Server Componentを直接importし、Client Component内で使用することはできません(例えば"use client"ファイル内でimport OrderDetail from "./OrderDetail")。Next.jsはOrderDetailがクライアント bundleに引き込まれたと見なし、async function componentがクライアント側上で有効ではないためエラーを報告します。childrenまたはpropsを通じたcompositionが正しい方法です。

6. Server ComponentからClient Componentへのデータの渡し方

Server ComponentからClient Componentに渡されるpropsはserializableである必要があります。つまり、JSONに変換できる必要があります。functionやclass instance、または複雑なDate objectは渡せません。

// app/components/PriceTag.tsx
"use client";

export default function PriceTag({ amount, currency }: { amount: number; currency: string }) {
  return <span>{amount.toLocaleString("ja-JP")} {currency}</span>;
}
// app/products/[id]/page.tsx
import PriceTag from "@/app/components/PriceTag";

export default async function ProductDetailPage({ params }: { params: { id: string } }) {
  const product = await fetch(`https://api.shop.local/products/${params.id}`).then(r => r.json());

  return (
    <div>
      <h1>{product.name}</h1>
      <PriceTag amount={product.price} currency="JPY" />
    </div>
  );
}

ここで、amount(number)とcurrency(string)は両方ともプリミティブデータであり、serialize可能です。Server ComponentからcallbackをfunctionとしてPriceTagに渡そうとすると、Next.jsはbuild/runtimeエラーを報告します:

Error: Functions cannot be passed directly to Client Components
unless you explicitly expose it by marking it with "use server".

サーバー側のロジックをクライアント側から呼び出したい場合(例えば、formをsubmit)、通常のfunctionを渡すのではなく、"use server"でマークされたServer Actionを使用します。

7. Server Action — 逆方向のブリッジ

Client Componentがサーバーをコールバック(データの書き込み、DBへのコール)する必要がある場合、API routeを別途作成するのではなく、Server Actionを使用します。

// app/actions/order-actions.ts
"use server";

export async function cancelOrder(orderId: string) {
  await fetch(`https://api.shop.local/orders/${orderId}/cancel`, { method: "POST" });
}
// app/components/CancelButton.tsx
"use client";

import { cancelOrder } from "@/app/actions/order-actions";
import { useState } from "react";

export default function CancelButton({ orderId }: { orderId: string }) {
  const [pending, setPending] = useState(false);

  async function handleClick() {
    setPending(true);
    await cancelOrder(orderId);
    setPending(false);
  }

  return (
    <button onClick={handleClick} disabled={pending}>
      {pending ? "キャンセル中..." : "注文をキャンセル"}
    </button>
  );
}
  • cancelOrderは"use server"でマークされており、Next.jsはClient Componentがサーバーをコールするためのhiddenなendpointを自動生成し、routeハンドラーを記述する必要はありません。
  • CancelButtonはそれをimportして通常のfunctionのように呼び出します。実際にはnetwork callですが、codeは自然に読めます。

8. 分離されたロジックのテストを記述する

Server Componentにはfetch/transformロジックが含まれることが多いため、純粋なロジック(JSXなし)を分離するとcomponent render全体をテストするよりもテストが簡単になります。

// app/lib/pricing.ts
export function formatPrice(amount: number, currency: string): string {
  if (amount < 0) {
    throw new Error("amountは負にできません");
  }
  return `${amount.toLocaleString("ja-JP")} ${currency}`;
}
// app/lib/pricing.test.ts
import { describe, it, expect } from "vitest";
import { formatPrice } from "./pricing";

describe("formatPrice", () => {
  it("有効な金額をフォーマットする", () => {
    expect(formatPrice(150000, "JPY")).toBe("150,000 JPY");
  });

  it("amountが負の場合、エラーをスロー", () => {
    expect(() => formatPrice(-1, "JPY")).toThrow("amountは負にできません");
  });
});

プロジェクトがClient Component用にJest + Testing Libraryを使用する場合(例えばLikeButton)、通常のReact componentと同じようにテストします。Jestは"use client"について何も知る必要はありません。このdirectiveはNext.jsのbundlerにのみ意味があり、runtimeテストには影響しません。

// app/components/LikeButton.test.tsx
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import LikeButton from "./LikeButton";

describe("LikeButton", () => {
  it("クリックすると状態が変わる", () => {
    render(<LikeButton />);
    const button = screen.getByRole("button");

    expect(button).toHaveTextContent("気に入る");
    fireEvent.click(button);
    expect(button).toHaveTextContent("気に入った");
  });
});

9. 一般的なエラー

App Routerに移行する際に遭遇する一般的なエラーはいくつかあります:

  • hookを使用する際に"use client"を忘れる: エラーuseState can not be used in Server Componentsが発生します。そのhookを含むファイルの先頭にdirectiveを追加します。それを呼び出すファイルではなく。
  • componentツリーで"use client"を高く配置しすぎる: layout.tsx全体をクライアント側としてマークすると、内部のすべて(子Server componentを含む)は直接importされた場合、client bundleに引き込まれ、bundleが不必要に膨らみます。必要最小限の最小componentのみをクライアント化する必要があります(例えば、ボタン、form input)。ページ全体ではなく。
  • サーバー側からクライアント側にserialize不可能なfunctionまたはobjectをpropsで渡す—callbackを渡すのではなくServer Action("use server")を使用します。
  • "use client"ファイル内のServer Componentをimportし、それがサーバー側で実行されることを期待する—それはできません。children/propsを通じたcompositionを使用する必要があります。
  • Client Component内で秘密鍵を使用してfetchを呼び出す—APIキーはブラウザに送信されるJS bundleに公開されます。秘密鍵が必要な操作は常にServer ComponentまたはServer Actionに配置する必要があります。

注意: 単に「古いcodeに似せるためにすべてをClient Componentに変換する」ことは安全ではありません。不要なClient Componentはそれぞれbundleサイズを増加させ、インタラクション時間(TTI)を遅くし、サーバー側でrenderする利点を失わせます。

10. 結論

Server ComponentはApp Routerのデフォルトであり、dataのfetch、機密リソースへのアクセス、およびJS bundleの削減に適しています。Client Componentは、state、effect、またはevent handlerがある場合にのみ使用する必要があります。この2つのtypeはcomposition(children、serializable props)とServer Actionを通じて結合され、Client Componentからサーバーロジックをコールバックするためのブリッジです。この境界を理解することで、bundleサイズを最適化し、機密データをサーバー側で安全に保つことができます。

nextjsreactserver-componentclient-componentapp-router