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になってしまう。
典型的な失敗パターン
// ❌ 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" にしてしまっている。Header や Footer もクライアントバンドルに入ることになる。
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: インタラクティブな部分だけを切り出す
// ❌ 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>
);
}
// ✅ 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>
)}
</>
);
}
// 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に保つ
// ❌ 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>
);
}
// ✅ 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 />}
</>
);
}
// 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 として受け取ることはできる。
// 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>
);
}
// 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
"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: 島を最小化した設計
// 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>}
</>
);
}
// 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>
);
}
// 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では使えない。
// ❌ app/layout.tsx でproviderを直接使いたいがclientにできない
import { ThemeProvider } from "./ThemeContext"; // "use client"が必要
// → layout.tsx 全体がclientになってしまう
// ✅ 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>
);
}
// 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をimport | childrenとして渡してcomposition |
| Providerは薄く切り出す | layout全体をclient | <Providers> ラッパーだけclient |
Island Architectureのメンタルモデルを意識すると判断しやすくなる。「このコンポーネントはインタラクティブである必要があるか?」をまず問う。Noなら迷わずServer Componentにする。Yesなら、できる限り小さな島として切り出す。
"use client" の伝播に注意して設計すれば、バンドルサイズを抑えつつ、インタラクティブなUIも実現できる。