4.

Next.js の use client とサーバー/クライアント境界

編集

この記事の要点

  • App Router では、レイアウトもページも既定でサーバーコンポーネント。ブラウザ側で動かしたいものだけを明示的に切り出す。
  • 'use client' はファイル先頭に書く境界の宣言。そのファイルが読み込むモジュールも一緒にクライアント側へ運ばれる。
  • state・イベント・ブラウザ API が必要ならクライアント、DB アクセスや秘密情報を扱うならサーバー、が基本の判断軸。
  • クライアントコンポーネントに children として渡したサーバーコンポーネントは、サーバー側で描画されたまま差し込まれる。
  • 境界をなるべく葉のほうへ寄せるほど、ブラウザに送る JavaScript が減る。

App Router を使い始めて最初にぶつかる壁が、コンポーネントが「どこで実行されるのか」という感覚です。React だけを使ってきた場合、コンポーネントはすべてブラウザで動くものでした。App Router では逆で、何も書かなければサーバーで実行されるのが既定です。この記事では、その境界の引き方と実務上の判断基準を整理します。

1どちらを使うかの判断軸

難しく考える前に、必要な機能から機械的に決められます。

やりたいこと置く場所
useState / useReducer で状態を持つクライアント
onClick / onChange などのイベントを扱うクライアント
useEffect でライフサイクル処理を書くクライアント
window / localStorage などブラウザ API を使うクライアント
データベースや内部 API から直接データを取るサーバー
API キーやトークンを使うサーバー
ブラウザに送る JavaScript を減らしたいサーバー
初回表示を速くしたい・段階的に流し込みたいサーバー

迷ったらサーバー側に置き、必要になった時点で切り出す、という順序が安全です。逆にすると、本来サーバーで完結できた処理までブラウザへ運ばれてしまいます。

2'use client' は境界の宣言

クライアントコンポーネントにするには、ファイルの先頭(インポートより上)'use client' と書きます。

// app/ui/counter.tsx
'use client'

import { useState } from 'react'

export default function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>{count} いいね</p>
      <button onClick={() => setCount(count + 1)}>押す</button>
    </div>
  )
}

ここで理解しておきたいのは、これが「このコンポーネントだけをクライアントにする指定」ではなく、サーバー側とクライアント側のモジュールの分かれ目を宣言するものだという点です。'use client' を付けたファイルが import したモジュールと、その中で直接描画しているコンポーネントは、まとめてクライアントのバンドルに含まれます。子コンポーネント一つ一つに書き足す必要はありません。

サーバー側で実行 layout.tsx page.tsx(DB 参照・秘密情報) ProductList.tsx JavaScript はブラウザに送られない props 'use client' 境界 LikeButton.tsx import した util.ts も同行する Modal.tsx ここだけがバンドルされて配信される
境界より内側にあるモジュールはすべてクライアントへ運ばれる。境界を上のほうに置くと、送らなくてよいコードまで巻き込む。

3境界は葉のほうへ寄せる

典型的な失敗は、レイアウト全体を 'use client' にしてしまうことです。ロゴとナビゲーションは静的なのに、検索ボックスが対話的だという理由だけで全体をクライアント化すると、送信する JavaScript が無駄に増えます。

// app/layout.tsx(サーバーコンポーネントのまま)
import Search from './search'   // これだけがクライアント
import Logo from './logo'       // サーバーのまま

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <nav>
        <Logo />
        <Search />
      </nav>
      <main>{children}</main>
    </>
  )
}

対話が必要な最小単位だけを別ファイルに切り出し、そのファイルにだけ 'use client' を書く。これが基本の型です。

4データの受け渡し

サーバーからクライアントへは props で値を渡します。ただし React がシリアライズできる値に限られるため、関数やクラスインスタンスはそのまま渡せません。

// app/[id]/page.tsx(サーバー)
import LikeButton from '@/app/ui/like-button'
import { getPost } from '@/lib/data'

export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const post = await getPost(id)

  return <LikeButton likes={post.likes} />
}

逆向き、つまりクライアントの中でサーバーの処理を呼びたい場合は props ではなく Server Functions を使います。

5入れ子にする — children というスロット

「モーダルの中にサーバーで取得した内容を出したい」というように、クライアントコンポーネントの内側にサーバーコンポーネントを置きたくなることがあります。直接 import すると境界に飲み込まれてしまいますが、children として渡せばサーバー側で描画された結果が差し込まれます。

// app/ui/modal.tsx
'use client'

export default function Modal({ children }: { children: React.ReactNode }) {
  return <div className="modal">{children}</div>
}
// app/page.tsx(サーバー)
import Modal from './ui/modal'
import Cart from './ui/cart'   // サーバーコンポーネントのまま

export default function Page() {
  return (
    <Modal>
      <Cart />
    </Modal>
  )
}

Cart は Modal の module graph に入らないため、サーバー側で描画されたまま渡されます。この「スロット」の考え方は、境界を下げるための最も実用的なテクニックです。

6Context プロバイダの置き方

React の Context はサーバーコンポーネントでは使えません。テーマなどの共有状態が必要な場合は、プロバイダをクライアントコンポーネントとして作り、レイアウトから使います。

// app/theme-provider.tsx
'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>
}

このときプロバイダは、できるだけツリーの深い位置に置くのが推奨されています。html 全体ではなく {children} だけを包むようにすると、静的な部分の最適化が効きやすくなります。

7サーバー専用コードを守る

モジュールはサーバーとクライアントの両方から読み込めてしまうため、API キーを含む処理をうっかりクライアントに持ち込む事故が起こり得ます。Next.js では NEXT_PUBLIC_ が付かない環境変数はクライアント側では空文字に置き換えられるので実害は出にくいのですが、意図しない読み込み自体を防ぐには server-only パッケージを使います。

// lib/data.ts
import 'server-only'

export async function getData() {
  // process.env.API_KEY を使う処理
}

これをクライアントコンポーネントから読み込もうとすると、ビルド時にエラーになります。逆に window を触るモジュールには client-only を使えます。

8つまずきやすいところ

よくあるエラー
useState が使えない」「window is not defined」「イベントハンドラをサーバーコンポーネントから渡せない」。いずれも 'use client' の付け忘れか、境界の位置が実態と合っていないサインです。
対処の順番
まず対話が必要な最小単位を特定 → そこだけ別ファイルに切り出す → 'use client' を付ける → 親はサーバーのまま props を渡す。この順で直すと境界が肥大しません。

サードパーティのコンポーネントが 'use client' を持たずに内部で useState を使っている場合も、サーバーコンポーネントから直接使うとエラーになります。その場合は自前のファイルで再エクスポートし、そこに 'use client' を書けば解決します。

境界の感覚がつかめたら、次はサーバー側でどうデータを取るかに進みます。

編集
Post Share
子ページ

子ページはありません

同階層のページ
  1. 開発環境の構築とプロジェクトの初期構成
  2. App Router のルーティングとファイル規約
  3. レイアウトと特殊ファイルの使い分け
  4. サーバーコンポーネントとクライアントコンポーネント
  5. サーバー側でのデータ取得と並列化
  6. ストリーミングで体感速度を上げる
  7. キャッシュの仕組みと use cache
  8. 再検証の使い分け
  9. フォーム送信とデータ更新の実装
  10. エラーハンドリングとエラー境界
  11. Route Handlers で API を作る
  12. メタデータ API で SEO を整える
  13. 画像とフォントの最適化
  14. 本番ビルドとセルフホスト
  15. バージョン16 への移行と注意点