32blogby Studio Mitsu

RSCで「use client」を最小化する設計

React Server Componentsで「use client」を増やしすぎない設計パターンを解説。Island Architecture・Composition Pattern・実践的リファクタリング例。

by omitsu17 min read
目次

RSCで "use client" を最小化するには、インタラクティブな部分だけを小さなClient Component「島」として切り出し、レイアウト・データ取得・静的コンテンツはServer Componentのまま保つ。 Composition Pattern(children propでサーバー側のコンテンツを渡す)を使えば、"use client" の伝播を防げる。

App Routerに移行してしばらくすると、ファイルの先頭に "use client" が増え続けていることに気づいた。気がつけばほぼ全コンポーネントがClient Componentになっていて、「これ、Pages Routerと何が違うんだろう」と思った経験がある。

RSCの恩恵(バンドルサイズ削減・サーバーでの直接データアクセス・セキュリティ)を最大限に引き出すには、"use client" の範囲を最小化する設計が重要だ。この記事では、Island ArchitectureとComposition Patternを使って "use client" を局所化する実践的な方法を解説する。

なぜ「use client」が増えすぎるのか?

"use client" を書く理由は明確だ。useState が必要、onClick が必要、useEffect が必要……という具合に、インタラクティブな機能を追加するたびに "use client" が必要になる。

問題は 「use client の伝播」 だ。

page.tsx (Server Component)
└── Layout.tsx ← "use client" を追加
    └── Header.tsx ← 自動的に Client Component になる
        └── Nav.tsx ← 自動的に Client Component になる
            └── NavItem.tsx ← 自動的に Client Component になる

"use client" は、そのコンポーネントとその子コンポーネント全体をクライアントバンドルに含める。一箇所に書くだけで、ツリー全体がClient Componentになってしまう。

典型的な失敗パターン

tsx
// ❌ Layout全体がClient Componentになってしまう
"use client"; // ← MobileMenuが必要だからここに書いてしまった

import { useState } from "react";
import { Header } from "./Header"; // Server Componentとして書いたのに...
import { Footer } from "./Footer"; // このFooterも全てクライアントになる
import { MobileMenu } from "./MobileMenu";

export function Layout({ children }: { children: React.ReactNode }) {
  const [menuOpen, setMenuOpen] = useState(false);

  return (
    <div>
      <Header />
      <MobileMenu isOpen={menuOpen} onClose={() => setMenuOpen(false)} />
      <main>{children}</main>
      <Footer />
    </div>
  );
}

MobileMenu のために useState が必要なのに、Layout 全体を "use client" にしてしまっている。HeaderFooter もクライアントバンドルに入ることになる。

Island Architectureという考え方

Island Architectureは、「静的なHTMLの海(ocean)の中に、インタラクティブな島(island)が浮かぶ」という設計思想だ。

┌─────────────────────────────────────────────────────┐
│  Server Component(静的なHTML)                       │
│                                                      │
│  ┌──────────────┐          ┌──────────────────────┐  │
│  │ "use client"  │          │  "use client"         │  │
│  │  (Island)    │          │   (Island)             │  │
│  │ SearchBox    │          │  LikeButton            │  │
│  └──────────────┘          └──────────────────────┘  │
│                                                      │
│  ┌──────────────┐                                    │
│  │ "use client"  │                                    │
│  │  (Island)    │                                    │
│  │ MobileMenu   │                                    │
│  └──────────────┘                                    │
└─────────────────────────────────────────────────────┘

インタラクティブな要素だけを "use client" にして、それ以外はServer Componentのまま保つ。Reactのツリー全体をクライアントにするのではなく、必要な「島」だけをクライアントにする発想だ。

Composition Patternで「use client」を局所化する

パターン1: インタラクティブな部分だけを切り出す

tsx
// ❌ Before: Layout全体がClient Component
"use client";
import { useState } from "react";

export function Layout({ children }: { children: React.ReactNode }) {
  const [menuOpen, setMenuOpen] = useState(false);
  return (
    <div>
      <nav>
        <a href="/">Home</a>
        <a href="/about">About</a>
        {/* このためだけにLayout全体がclientになっている */}
        <button onClick={() => setMenuOpen(o => !o)}>Menu</button>
        {menuOpen && <MobileMenuItems />}
      </nav>
      <main>{children}</main>
    </div>
  );
}
tsx
// ✅ After: MobileMenuだけをClient Componentにする

// components/MobileMenu.tsx
"use client"; // ← ここだけ
import { useState } from "react";

export function MobileMenu() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(o => !o)}>Menu</button>
      {open && (
        <div className="mobile-menu">
          <a href="/">Home</a>
          <a href="/about">About</a>
        </div>
      )}
    </>
  );
}
tsx
// app/layout.tsx — Server Componentのまま
import { MobileMenu } from "@/components/MobileMenu";

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div>
      <nav>
        <a href="/">Home</a>
        <a href="/about">About</a>
        <MobileMenu /> {/* Client Componentだが、Layout自体はServerのまま */}
      </nav>
      <main>{children}</main>
    </div>
  );
}

Layout はServer Componentのまま維持できる。MobileMenu だけが "use client" になる。

パターン2: children propで親をServerに保つ

tsx
// ❌ Before: データ取得コンポーネントがclientになってしまう
"use client";
import { useEffect, useState } from "react";

export function ProductSection() {
  const [products, setProducts] = useState([]);
  const [cartOpen, setCartOpen] = useState(false);

  useEffect(() => {
    fetch("/api/products").then(r => r.json()).then(setProducts);
  }, []);

  return (
    <section>
      <div className="grid">
        {products.map(p => <ProductCard key={p.id} product={p} />)}
      </div>
      <CartButton isOpen={cartOpen} onClick={() => setCartOpen(o => !o)} />
    </section>
  );
}
tsx
// ✅ After: データ取得はServer Component、UIの制御はClient Componentに分離

// components/CartButton.tsx
"use client";
import { useState } from "react";

export function CartButton() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(o => !o)}>
        カート {open ? "▲" : "▼"}
      </button>
      {open && <CartPanel />}
    </>
  );
}
tsx
// app/products/page.tsx — Server Component(データ取得)
import { db } from "@/lib/db";
import { ProductCard } from "@/components/ProductCard";
import { CartButton } from "@/components/CartButton";

export default async function ProductSection() {
  // DBに直接アクセス。APIレイヤー不要
  const products = await db.product.findMany();

  return (
    <section>
      <div className="grid">
        {products.map(p => <ProductCard key={p.id} product={p} />)}
      </div>
      <CartButton /> {/* ← Client Componentだが、ページ自体はServerのまま */}
    </section>
  );
}

パターン3: childrenを通じてServer ComponentをClient Componentの「穴」に渡す

Client ComponentはServer Componentを直接インポートできないが、children として受け取ることはできる。

tsx
// components/Accordion.tsx
"use client";
import { useState, ReactNode } from "react";

export function Accordion({
  title,
  children,
}: {
  title: string;
  children: ReactNode;
}) {
  const [open, setOpen] = useState(false);

  return (
    <div className="border rounded-lg overflow-hidden">
      <button
        className="w-full text-left p-4 font-bold"
        onClick={() => setOpen(o => !o)}
      >
        {title} {open ? "▲" : "▼"}
      </button>
      {open && <div className="p-4">{children}</div>}
    </div>
  );
}
tsx
// app/faq/page.tsx — Server Component
import { db } from "@/lib/db";
import { Accordion } from "@/components/Accordion";

export default async function FaqPage() {
  // DBから直接FAQデータを取得
  const faqs = await db.faq.findMany({ orderBy: { order: "asc" } });

  return (
    <main>
      <h1>よくある質問</h1>
      {faqs.map(faq => (
        // Accordionはclientだが、childrenとしてServer側のデータを渡せる
        <Accordion key={faq.id} title={faq.question}>
          {/* ここはServer Componentの世界 */}
          <p>{faq.answer}</p>
          {faq.relatedLinks.map(link => (
            <a key={link.url} href={link.url}>{link.label}</a>
          ))}
        </Accordion>
      ))}
    </main>
  );
}

Accordion のopen/closeロジックはClient Componentが管理するが、children に渡すコンテンツはServer Component側で生成される。DBアクセスや重いデータ変換はサーバー側に留められる。

実践:ブログ記事ページのリファクタリング

よくあるブログ記事ページを例に、リファクタリングの全体像を見てみよう。

❌ Before: ページ全体がClient Component

tsx
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";

export default function BlogPostPage() {
  const { slug } = useParams();
  const [post, setPost] = useState(null);
  const [liked, setLiked] = useState(false);
  const [tocOpen, setTocOpen] = useState(false);

  useEffect(() => {
    fetch(`/api/posts/${slug}`).then(r => r.json()).then(setPost);
  }, [slug]);

  if (!post) return <div>読み込み中...</div>;

  return (
    <article>
      <h1>{post.title}</h1>
      {/* 目次の開閉 */}
      <button onClick={() => setTocOpen(o => !o)}>目次</button>
      {tocOpen && <TableOfContents headings={post.headings} />}
      {/* 記事本文 */}
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
      {/* いいねボタン */}
      <button onClick={() => setLiked(o => !o)}>
        {liked ? "❤️" : "🤍"} いいね
      </button>
    </article>
  );
}

問題点:

  • ページ全体がclient → 記事本文もJSバンドルに含まれる
  • クライアントでAPIを叩く → 余分なリクエスト
  • ローディング状態の管理が複雑

✅ After: 島を最小化した設計

tsx
// components/TableOfContentsToggle.tsx
"use client";
import { useState, ReactNode } from "react";

export function TableOfContentsToggle({ children }: { children: ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button
        className="text-sm text-gray-500 underline"
        onClick={() => setOpen(o => !o)}
      >
        {open ? "目次を閉じる" : "目次を開く"}
      </button>
      {open && <div className="my-4 p-4 bg-gray-50 rounded-lg">{children}</div>}
    </>
  );
}
tsx
// components/LikeButton.tsx
"use client";
import { useState } from "react";

export function LikeButton({ postId }: { postId: string }) {
  const [liked, setLiked] = useState(false);

  const handleLike = async () => {
    setLiked(o => !o);
    await fetch(`/api/posts/${postId}/like`, { method: "POST" });
  };

  return (
    <button onClick={handleLike} className="flex items-center gap-2">
      {liked ? "❤️" : "🤍"} いいね
    </button>
  );
}
tsx
// app/blog/[slug]/page.tsx — Server Component
import { db } from "@/lib/db";
import { marked } from "marked";
import { TableOfContentsToggle } from "@/components/TableOfContentsToggle";
import { LikeButton } from "@/components/LikeButton";
import { notFound } from "next/navigation";

export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await db.post.findUnique({
    where: { slug },
  });

  if (!post) notFound();

  const html = marked(post.content); // サーバーで変換。markedのJSはバンドルに入らない

  return (
    <article>
      <h1>{post.title}</h1>

      {/* 目次のトグルはclientだが、目次のリンクはserverで生成 */}
      <TableOfContentsToggle>
        <nav>
          {post.headings.map(h => (
            <a key={h.id} href={`#${h.id}`} className="block py-1">
              {h.text}
            </a>
          ))}
        </nav>
      </TableOfContentsToggle>

      {/* 記事本文はServer Componentとして生成。JSバンドルに含まれない */}
      <div dangerouslySetInnerHTML={{ __html: html }} />

      {/* いいねボタンだけがclient */}
      <LikeButton postId={post.id} />
    </article>
  );
}

Context Providerの設計

グローバルなstateを管理するContext Providerも、Server Componentのルートに近い場所に置きたくなるが、createContext はServer Componentでは使えない。

tsx
// ❌ app/layout.tsx でproviderを直接使いたいがclientにできない
import { ThemeProvider } from "./ThemeContext"; // "use client"が必要
// → layout.tsx 全体がclientになってしまう
tsx
// ✅ Providerを薄いClient Componentとして切り出す

// components/Providers.tsx
"use client"; // ← このファイルだけclient
import { ThemeProvider } from "@/contexts/ThemeContext";
import { UserProvider } from "@/contexts/UserContext";
import { ReactNode } from "react";

export function Providers({ children }: { children: ReactNode }) {
  return (
    <ThemeProvider>
      <UserProvider>
        {children}
      </UserProvider>
    </ThemeProvider>
  );
}
tsx
// app/layout.tsx — Server Componentのまま
import { Providers } from "@/components/Providers";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body>
        {/* Providersだけがclient。layoutはserverのまま */}
        <Providers>
          {children}
        </Providers>
      </body>
    </html>
  );
}

Providers という薄いラッパーClient Componentを作り、そこに全てのProviderをまとめる。layout.tsx はServer Componentのまま維持できる。これはNext.jsの公式ドキュメントでも推奨されているパターンだ。

効果を測定する

リファクタリングの効果はどう確認するか。修正前後で以下を比較しよう。

  • バンドルサイズ: npx @next/bundle-analyzer やビルド出力を確認。Client Componentは「First Load JS」に表示される
  • Core Web Vitals: クライアントJSが減ればLCPとINPが改善する
  • React DevTools Profiler: React DevTools の「Components」タブでServer/Client Componentを確認できる。意図しないClient Componentがないかチェックしよう

Next.jsのドキュメントにもある通り、デフォルトはServer Component。"use client" はインタラクティビティ・hooks・ブラウザAPIが必要なときだけ追加する。

FAQ

「use client」を書くとサーバーでは実行されないの?

いいえ。Client ComponentもサーバーでHTMLとしてプリレンダリングされる(Pages RouterのSSRと同じ)。違いは、そのJavaScriptがクライアントバンドルに含まれ、ハイドレーション後にインタラクティブになる点だ。

Client ComponentからServer Componentをimportできる?

直接はできない。Client Componentが import するとモジュールグラフ上でクライアントバンドルに強制的に含まれてしまう。ただし、children や他のpropsとして渡すことはできる。これがこの記事で解説したComposition Patternだ。

Server → Client Componentに渡せるデータの制限は?

シリアライズ可能なデータのみ。文字列・数値・真偽値・配列・プレーンオブジェクト・Date・Map・Set・TypedArray・FormData・React要素(JSX)が渡せる。関数・クラスインスタンス・循環参照は境界を越えられない。

大きな「use client」ラッパー1つと小さなもの複数、どちらがいい?

小さなもの複数。各 "use client" ディレクティブは境界を作る。境界が小さいほどクライアントに送るJavaScriptが減る。大きなラッパー1つではServer Componentsの意味がなくなる。

Context ProviderはServer Componentで使える?

Context"use client" が必要なので、Server Componentでは useContext を使えない。推奨パターンは薄い <Providers> Client Componentラッパーを作り、ルートlayoutで children を通して配置すること。layout自体はServer Componentのまま維持できる。

「use client」はSEOに影響する?

直接は影響しない。Client ComponentもサーバーでHTMLとしてレンダリングされるからだ。ただし、"use client" によってデータ取得がクライアントサイド(useEffect + fetch)になると、検索エンジンがそのコンテンツを見られない可能性がある。データ取得はServer Componentで行うのが安全だ。

「use server」と「use client」の違いは?

"use server" はClient Componentから呼べるサーバーサイド関数(Server Actions)をマークする。フォーム送信やデータ変更に使う。"use client" はクライアントサイドのインタラクティビティが必要なコンポーネントをマークする。用途が異なる。

まとめ

"use client" を最小化する設計のポイントをまとめる。

原則Before(悪い例)After(良い例)
インタラクティブな部分だけ切り出す親コンポーネント全体をclient必要な最小コンポーネントだけclient
データ取得はServer ComponentでuseEffect + fetchでクライアント取得Server Componentで直接DBアクセス
childrenで「穴」を作るClient Component内でServer Componentをimportchildrenとして渡してcomposition
Providerは薄く切り出すlayout全体をclient<Providers> ラッパーだけclient

Island Architectureのメンタルモデルを意識すると判断しやすくなる。「このコンポーネントはインタラクティブである必要があるか?」をまず問う。Noなら迷わずServer Componentにする。Yesなら、できる限り小さな島として切り出す。

"use client" の伝播に注意して設計すれば、バンドルサイズを抑えつつ、インタラクティブなUIも実現できる。

関連記事: