React 18 以降の新しい境界線 ― 'use client' / 'use server' の設計思想
はじめに:2つの世界をつなぐドア
React 18 以降に導入された React Server Components(RSC) は、従来の React の前提を大きく変えました。
これまでは「React コンポーネント = クライアントで動くもの」という固定観念が強く、SSR(Server Side Rendering)や SSG(Static Site Generation)を組み合わせることはできても、「サーバーの強み」と「クライアントの強み」を1つの抽象の中に滑らかに共存させるのは難しかったのです。
その壁を突破するために登場したのが、'use client' と 'use server' という 2 つのディレクティブです。
Dan Abramov 氏は自身のブログ What Does "use client" Do? の記事の中で、これらを 「2つの世界をつなぐドア」 と表現しました。
'use client'
→ サーバーからクライアントへのドア(typed <script>)
'use server'
→ クライアントからサーバーへのドア(typed fetch())
本記事では、Dan 氏の解説を参考にしながら、この比喩を起点としてそれぞれのディレクティブの役割と仕組みを簡潔にまとめたいと思います。
1. React は「2つの世界」にまたがる
RSC を理解する上で欠かせない前提は、RSCを利用したReact アプリは「サーバーの世界」と「クライアントの世界」という 2 台のコンピュータにまたがる 1 つのプログラムで成り立っているということです。
サーバーの世界
- データベースやファイルシステムに直接アクセスできる
- 認証や権限チェックをセキュアに行える
- 膨大なデータを処理したうえで、必要な部分だけをクライアントに渡せる
クライアントの世界
- useState や useEffect といったフックで UI の状態管理ができる
- ユーザーのクリックや入力といったイベントを即時に処理できる
- DOM に直接アクセスしてインタラクティブな UI を構築できる
この「2つの世界」はそれぞれ異なる権限や処理基盤を持つため、従来のアプリ設計では、REST API や GraphQL のような明示的な境界を設けていました。
ところが RSC は、モジュールシステムをそのまま活かしながら、両者を自然につなぐことができる設計を可能にしました。
こちらの @akfm_sato さんの投稿 では、このサーバーとクライアントの関係を図解で直感的に示していて、とても分かりやすいです。
2. 'use client' ― サーバーからクライアントに開くドア
2.1 従来の <script> の世界
React に限らず、従来の Web では サーバーが返すのは「HTML+スクリプト文字列」 でした。
<!-- サーバーが生成した HTML -->
<button id="likeButton" data-postid="42">8 Likes</button>
<script src="/frontend.js"></script>
<script>
// 文字列で関数名+JSONを渡す
LikeButton({ postId: 42, likeCount: 8, isLiked: true });
</script>
ここで起きていることは以下です:
- どの関数を呼ぶか(例: LikeButton)を文字列で指定
- どんなデータを渡すか(例: { postId: 42, likeCount: 8 })もJSON文字列で埋め込む
つまり、ブラウザに送られるのは「型のないただの文字列情報」であり、
- 関数名のタイプミス
- プロパティの食い違い
- データ直列化の失敗
などは 実行時まで発見できません。これが “stringly-typed” と呼ばれる所以です。
2.2 'use client' の世界:型付き <script>
ここで登場するのが 'use client' ディレクティブです。Dan氏は記事の中でこれを “typed <script>” と表現しています。
// client.tsx
'use client';
export function LikeButton(props: { postId: number; likeCount: number; isLiked: boolean }) {
return (
<button className={props.isLiked ? 'liked' : ''}>
{props.likeCount} Likes
</button>
);
}
// server.tsx
import { LikeButton } from './client';
export default function Page({ post }) {
return <LikeButton postId={post.id} likeCount={post.likes} isLiked />;
}
この構成では:
- サーバー側は import { LikeButton } と構文的につなぐ
- JSX の <LikeButton …/> はビルド時に「クライアント参照」へ変換される
(例: /src/frontend.js#LikeButton のような識別子) - 実行時には必要な <script> と呼び出しコードが自動生成される
結果として:
- 型で検証できる(props が直列化可能かもチェックされる)
- 参照追跡やツリーシェイクが効く
- バンドル最適化も可能
2.3 まとめ
'use client' は「ただの <script> 挿入」を モジュールシステムに統合し、型付きで扱えるようにしたものと言えます。
これは「サーバーの部屋にある小窓から、クライアントの作業台へ部品を安全に渡す」イメージです。
以前は「紙に書いた指示を窓から投げ込む」しかできなかったのが、今はモジュール境界を通じて構造化された部品を渡せるようになったのです。
- 従来:<script> タグで「関数名+JSON」を文字列で渡す。型安全性なし。
- 'use client':import/export で構文的に接続。IDEや型システムがサーバ→クライアントの橋渡しをサポート。
'use server'
― クライアントからサーバーに通じるドア
3. 3.1 従来の世界:文字列ベースの fetch/API 呼び出し
これまでクライアントからサーバーへ処理を依頼するには、文字列でルートを指定し、JSON を組み立てて送る必要がありました。
// API Route (/api/like)
export async function POST(req: Request) {
const { postId } = await req.json()
const count = await db.like(postId)
return Response.json({ count })
}
// Client Component
async function onClick() {
const res = await fetch('/api/like', {
method: 'POST',
body: JSON.stringify({ postId }),
})
const { count } = await res.json()
setCount(count)
}
- エンドポイントURLは文字列(typoに弱い)
- リクエストBodyは JSON.stringify(手作業で直列化)
- レスポンスの型は実行時まで分からない
いわば「stringly-typed fetch()」の世界であり、ルート設計や型の同期を人間が頑張って維持するしかありませんでした。
'use server'
の世界:型付き fetch
3.2 ここで 'use server'
を導入すると、サーバー関数を普通の関数として書けるようになります。Dan氏はこれを “typed fetch()
” と表現しています。
// actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function likePost(postId: number) {
const count = await db.like(postId)
revalidateTag('post:' + postId)
return { count }
}
// Client Component
import { likePost } from './actions'
export default function LikeButton({ postId }: { postId: number }) {
return (
<button
onClick={async () => {
const { count } = await likePost(postId) // ← 普通の関数呼び出しに見える
setCount(count)
}}
>
Like
</button>
)
}
- クライアントからは 普通の関数呼び出しに見える
- 実際には裏で 直列化 → ネットワークPOST → サーバー実行 → 直列化して返却 が走る
- 関数シグネチャで型が担保され、props/戻り値が直列化可能かどうかも検証される
つまり 'use server'
は、従来の「stringly-typed fetch」をモジュールシステムに統合し、型付きで扱えるようにした仕組みなのです。
3.3 用途と制約
- 主に 更新系(ミューテーション) で利用:フォーム送信・データ作成/更新/削除・認証が必要な操作など
- 非同期関数である必要がある(ネットワーク越しのため)
- 引数/戻り値は直列化できる値に限定(関数やクラスインスタンスは渡せない)【React Docs】
- 実行は必ずサーバー内で行われ、DBアクセスや秘密情報はクライアントに漏れない
3.4 まとめ
つまり、'use server'
は「クライアントの部屋にあるインターホンで、サーバー係員に処理をお願いする」イメージです。
従来は「住所を書いた手紙を投函する」しか方法がなかったのが、今は 型安全なインターホン 越しにサーバーへ依頼できるようになった、と言えるでしょう。
-
従来:
fetch('/api/...')
のように 文字列エンドポイント+JSON で通信 -
'use server'
:普通の 関数呼び出しに見えるが、裏では型付きで安全な RPC が実行される
4 サーバーとクライアントを交互にネストする
4.1 サーバーとクライアントの強みを一つの抽象に束ねる
RSC の革新的な点は、「サーバーとクライアントを交互に入れ子にできる構造を、React ツリーとして自然に記述できる」 ということです。Dan氏はこの記事 Impossible Components でその設計思想に言及しています。
この構造によってデータと UI のフローを 1 つの React ツリー内でシームレスに記述できます。
これは、視覚的に「サーバー→クライアント→サーバー」のネスト構造が”ドーナツ状”に見えるという表現に近く、 @t6adev さんが投稿された図も非常に直感的にこの構造を表現しています。
Server: <Layout> … ページ枠・認証・テーマなどサーバー文脈
└─ Client: <Tabs> … インタラクション・アクティブ状態の管理(useState 等)
├─ Server: <Content /> … パネルA:サーバー専用データを描画
└─ Server: <Content /> … パネルB:サーバー専用データを描画
4.2 コード例(Tabs は children を受け取る)
1) 外側レイアウト:Server(ページ枠・認証・テーマなどサーバー文脈)
// Layout.server.tsx
export default async function Layout({ children }: { children: React.ReactNode }) {
// 例:サーバー専用の文脈をここで解決
const user = await getCurrentUser() // 認証
const theme = await getTheme(user?.id) // テーマ
return (
<div className={`layout theme-${theme}`}>
<header>
<h1>My App</h1>
{user ? <span>Welcome, {user.name}</span> : <a href="/login">Login</a>}
</header>
<main>{children}</main>
</div>
)
}
2) タブ UI:Client(ヘッダー=ボタン生成と切り替えを内包)
// Tabs.client.tsx
'use client'
import { useState } from 'react'
export default function Tabs({ children }: { children: React.ReactNode }) {
const [active, setActive] = useState(0)
const items = React.Children.toArray(children)
return (
<section className="tabs">
<div className="tab-headers">
{items.map((_, i) => (
<button
key={i}
aria-selected={i === active}
onClick={() => setActive(i)}
>
Tab {i + 1}
</button>
))}
</div>
<div className="tab-panel">{items[active]}</div>
</section>
)
}
3) 各パネル:Server(サーバーでしか扱えないデータを描画)
// Content.server.tsx
export default async function Content({ resource }: { resource: string }) {
// 例:DB/外部API/権限チェックなどサーバー専用の処理
const data = await fetchSecureData(resource)
return (
<div className="content">
<h2>{resource}</h2>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
)
}
4) 組み合わせ:Server が Server 要素を children として渡す
// Page.server.tsx
import Layout from './Layout.server'
import Tabs from './Tabs.client'
import Content from './Content.server'
export default async function Page() {
return (
<Layout>
<Tabs>
<Content resource="overview" />
<Content resource="details" />
</Tabs>
</Layout>
)
}
依存方向は常に Server → Client 。Client 側(Tabs
)は Server コンポーネントを import せず、Server が生成した要素(<Content />
)を children として受け取る だけ。
4.4 アーキテクチャ的な意義
- バンドルサイズの最適化:クライアントに送るコードを最小化できる
- セキュリティの担保:DBアクセスや権限チェックはサーバーに閉じ込められる
- 自然な抽象化:UIの設計をクライアント・サーバーで分けずにモジュールとして扱える
5. まとめ
'use client' はサーバーからクライアントに開く小窓 ― typed <script>
'use server' はクライアントからサーバーに通じるインターホン ― typed fetch()
2つのドアにより、サーバーの強み(データ/権限)とクライアントの強み(UI/イベント処理)を、モジュールシステムの中で型安全に組み合わせられることが可能となりました。
“ドアは必要最小限に”
'use client' を書いたモジュール配下はすべてクライアントに送られるため、安易に上層に書くと JS バンドルが肥大化します。できるだけ末端に近い UI 部品だけに限定するのがベストプラクティスです。
'use server' は「機能の入口」
サーバー関数は DB アクセスや認証などを集中管理できるポイントです。
ただし、引数や戻り値はシリアライズ可能な値に限られるため、設計時に注意が必要です。
import で世界をまたげる
通常の JS モジュールと同じ import 構文で、サーバーとクライアントの境界をまたげる。
この「自然さ」こそが RSC の強みであり、Dan 氏が「構造化プログラミング(if / while)、第一級関数、async/awaitと同レベルで、Reactを通り越して生き残り常識となる」と期待する理由です。
Discussion