32blogby StudioMitsu
react13 min read

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

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

reactRSCserver-componentsnext.jsapp-router設計
目次

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の公式ドキュメントでも推奨されているパターンだ。

まとめ

"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も実現できる。