フロントエンド

Next.js App RouterでServer ComponentsとClient Componentsを使い分ける

読了 8分
フロントエンド

Next.js App RouterでServer ComponentsとClient Componentsを使い分ける

Next.jsのApp Routerに移ったとき、'use client' をどこに置けばいいのか分からず、とりあえず付けて回って後で困ったので、そのときに整理したメモです。

app ディレクトリ内のコンポーネントは 既定でServer Component(サーバーコンポーネント) として扱われます。ブラウザで動かすには、明示的に 'use client' を宣言する必要があります。この境界の引き方が分かると、無駄なJavaScriptをクライアントに送らずに済んで、表示も速くなります。

まず既定はサーバー側

Server Componentはサーバー上でだけ実行されて、レンダリング結果のHTMLがクライアントに送られます。コンポーネント自体のJavaScriptはブラウザにバンドルされません。つまり 何もしなければ、クライアントに送るJSはゼロに近づく ということです。

Server Componentでは次のことが自然にできます。

  • async 関数にして、その場でデータ取得(DBアクセスやfetch)
  • APIキーなどの秘密情報を扱う(クライアントに漏れない)
  • 大きなライブラリをサーバー側だけで使う
// app/posts/page.tsx('use client' がないのでServer Component)
export default async function PostsPage() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60 },
  })
  const posts = await res.json()

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

こんなふうにコンポーネントを async にして直接データを取得できるのが、App Routerの大きな利点だと思います。最初は「えっ、コンポーネントで await していいの?」と戸惑いました。

Client Componentが必要になる場面

一方、ブラウザでしか動かせない処理にはClient Componentが要ります。ファイルの先頭に 'use client' と書くと、そのファイルと、そこからimportされるコンポーネントがクライアント側でも動くようになります。

Client Componentが必要なのは、主にこんな場合です。

  • onClick などのイベントハンドラを使う
  • useState / useEffect などのフックを使う
  • windowlocalStorage などブラウザ専用APIを使う
'use client'

import { useState } from 'react'

export default function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount((c) => c + 1)}>count: {count}</button>
}

判断の軸はシンプルで、「ユーザー操作やブラウザAPIに反応する必要があるか」です。反応が要るならClient、要らないならServerのままにします。

つまずき1:'use client' を上のほうに付けすぎる

自分がやった失敗がこれです。ページの最上位コンポーネントに 'use client' を付けてしまうと、その配下すべてがClient Componentになって、サーバーで完結できたはずの部分までクライアントに送られます。

コツは インタラクティブな部分だけを小さなClient Componentに切り出して、境界をできるだけ葉(末端)に近づける ことのようです。

// app/page.tsx(Server Componentのまま)
import LikeButton from './LikeButton' // これだけがClient Component

export default async function Page() {
  const article = await getArticle()
  return (
    <article>
      <h1>{article.title}</h1>
      <p>{article.body}</p>
      <LikeButton id={article.id} /> {/* インタラクティブな部分だけ分離 */}
    </article>
  )
}

記事本文はServerのままサーバーでレンダリングして、「いいねボタン」だけをClientにする。この切り分けが基本形のようです。

つまずき2:Server Componentに関数やクラスインスタンスを渡す

Server ComponentからClient Componentへpropsを渡すとき、その値は シリアライズ可能 でなければなりません。文字列・数値・配列・プレーンなオブジェクトはOKで、DateMap / Set などもReact Server Componentsがシリアライズに対応しています。一方で、独自のメソッドを持つクラスのインスタンスや関数は復元できないので渡せません。

// NG:関数はpropsとして渡せない
<Chart onRender={() => doSomething()} />

ここでエラーになって、最初は理由が分からず困りました。イベントハンドラのようなロジックは、受け取った側(Client Component)の中で定義します。データだけをpropsで渡して、振る舞いはClient側に持たせる、と整理すると破綻しにくいです。

サーバーとクライアントの組み合わせ方

Client Componentの children としてServer Componentを差し込むこともできます。これを使うと、Clientの「殻」の中にServerの中身を入れられます。

// Client Component(開閉などの状態を持つ)
'use client'
export function Collapsible({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false)
  return (
    <div>
      <button onClick={() => setOpen((o) => !o)}>{open ? '閉じる' : '開く'}</button>
      {open && children}
    </div>
  )
}
// 呼び出し側(Server Component)。children に渡す中身はServerのまま
<Collapsible>
  <ServerRenderedContent />
</Collapsible>

「状態を持つ枠」はClient、「中身」はServer、という分担が自然に書けます。これを知ってからだいぶ楽になりました。

まとめ

  • App Routerの既定はServer Component。データ取得や秘密情報はこちら
  • イベント・フック・ブラウザAPIが要るときだけ 'use client'
  • 境界はできるだけ末端へ。最上位に付けない
  • propsはシリアライズ可能な値のみ。関数は渡さない

考え方の中心は「動的な操作が要る最小範囲だけClientにする」だと思います。詳しい仕様はNext.js公式のServer/Client Componentsドキュメントも合わせて見ると理解が深まります。以上です。