"use client" は JavaScript 標準ではない - 「出所不明」な文字列が隠すリスク
はじめに
"use client" や "use server" といったディレクティブは、React Server Components(RSC)の普及とともに広く使われるようになりました。一見すると、これらは JavaScript の標準機能である "use strict" のように見えますが、実際には根本的に異なるものです。
TanStack の作者である Tanner Linsley 氏は、ブログ記事[1]で、これらのディレクティブが新しい形のフレームワークロックインを生み出していると警鐘を鳴らしています。ディレクティブは開発者体験を向上させる一方で、標準化されていない独自仕様であり、エコシステムが分断されたり、他のフレームワークへの乗り換えが難しくなったりといった長期的なリスクを孕んでいます。
本記事では、Tanner Linsley 氏のブログ記事と関連する議論をもとに、以下のテーマを深掘りします。
- ディレクティブとは何か、なぜ問題なのか
- 「標準技術」と「独自機能」の境界線が曖昧になることのリスク
- フレームワークロックインとエコシステムの断片化
- 明示的なアプローチという選択肢
第1部:ディレクティブの基礎と問題の本質 🔍
ディレクティブとは何か
JavaScript において、ディレクティブとは特定の動作を指示する文字列リテラルです。最も有名な例は "use strict" です。
"use strict";
// 厳格モードが有効になる
x = 3.14; // エラー: 変数の宣言が必要
"use strict" は ECMAScript 5(2009年)で標準化されており、すべての JavaScript ランタイム(Node.js、ブラウザなど)が理解し、同じ動作を保証します。
RSC エコシステムにおけるディレクティブ
React Server Components では、"use client"と "use server"という異なる役割を持つディレクティブが使われます。
// サーバーコンポーネント(デフォルト)
async function BlogPost() {
const post = await fetchPost()
return <article>{post.content}</article>
}
// クライアントコンポーネント
'use client'
function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>{count}</button>
}
// サーバーアクション
'use server'
async function updatePost(formData) {
await db.posts.update(formData)
}
これらのディレクティブは、サーバーとクライアントの境界を定義し、バンドラーに対して以下を指示します。
-
"use client"— このモジュールとその依存関係をクライアントバンドルに含める -
"use server"— この関数をサーバー側でのみ実行可能にし、POST エンドポイントを生成する
そして、ここ2年ほどでディレクティブは急速に増加しています。
| ディレクティブ | 提供元 | 目的 | ステータス |
|---|---|---|---|
"use client" |
React/Next.js | クライアントコンポーネント | 安定版 |
"use server" |
React/Next.js | サーバーアクション | 安定版 |
"use cache" |
Next.js 16 | キャッシング | 安定版[2] |
"use workflow" |
Vercel | AI ワークフロー | 発表済み |
"use memo" |
React Compiler | メモ化の有効化 | 公式ドキュメント化[3] |
"use no memo" |
React Compiler | メモ化の無効化 | 公式ドキュメント化[4] |
これらはすべて JavaScript や TypeScript の標準ではなく、独自仕様です。
仕組みの詳細
ディレクティブは、JavaScriptランタイムではなく、ビルドツール(Next.jsのTurbopackやWebpackなど)によって解釈されます。バンドラーはディレクティブに基づいて、次のようにコードを振り分けます。
-
"use client"— クライアントバンドルに含める - ディレクティブなし — サーバー専用(クライアントバンドルに含めない)
サーバーはクライアントコンポーネントを「モジュール参照」として送信し、実際のコードはクライアント側で実行されます。
// サーバー側で実行
async function ServerComponent() {
const data = await fetchData() // サーバー側でのみ実行
return <ClientPart initialData={data} /> {/* モジュール参照 */}
}
プラットフォームとフレームワークの境界
Tanner Linsley 氏が問題視しているのが、プラットフォーム境界(Platform Boundary) という考え方です。ここでいう「プラットフォーム」とは、標準化された、複数の実装間で共通の仕様を持つ基盤を指します。
| 種別 | 説明 | 例 |
|---|---|---|
|
プラットフォーム (標準) |
ECMAScript、Web標準など 仕様書が存在し、複数実装で同じ動作 |
"use strict", fetch, DOM API |
|
フレームワーク (実装) |
特定の実装、独自規約・API | Next.js の cookies(), Remix の loader
|
従来、フレームワーク固有の機能は明示的な import で提供され、開発者は next/headers などのインポートを見て「これは Next.js 固有」と即座に認識できました。
しかし、ディレクティブはこの境界を曖昧にします。"use client" は "use strict" と同じ見た目ですが、JavaScript 仕様には存在せず、バンドラー(Next.jsの場合はTurbopack/Webpack)が解釈する独自仕様です。import文のような明示的なマーカーがないため、プラットフォーム(標準)とフレームワーク(実装)の境界が曖昧になるのです。
なぜディレクティブは境界を曖昧にするのか
Tanner Linsley 氏は、ディレクティブの問題を理解するためにJSXとの比較とグローバル関数への例えを用いています[1:1][5]。
JSXには「出所(provenance)」があった
JSXも当初は「魔法」のように見えましたが、明確な境界がありました。
// JSX - 出所が明確
// ファイル名: Counter.tsx ← 専用拡張子
import React from 'react' // ← 明示的インポート
function Counter() {
return <button>Click</button>
}
-
.jsx/.tsxという専用拡張子 -
React.createElement()への変換仕様が明確 - Babel/TypeScript というツールチェーンの明示
一方、ディレクティブには出所がありません。
// ディレクティブ - 出所が不明
'use client' // ← どこから来た?何に依存?
import { useState } from 'react'
function Counter() { /* ... */ }
通常の .tsx 拡張子、インポート不要、ファイル先頭に文字列を書くだけ。どのバンドラー、どのフレームワーク、どのバージョンに依存しているか不明瞭です。
ディレクティブは「importのないグローバル関数」
Tanner Linsley 氏は、ディレクティブをグローバル関数に例えています[1:2]。
// ディレクティブ
'use cache'
const fn = () => 'value'
// ≒ グローバル関数(問題あり)
window.useCache()
const fn = () => 'value'
// 対して、明示的なAPI(推奨)
import { cache } from 'next/cache' // ← 出所明確、バージョン管理可能
export const fn = cache(() => 'value')
グローバル関数の問題点:
- 提供元が不明
- バージョン指定不可
- モック化・置き換え困難
- TypeScript、ESLint、IDEのサポートが限定的
ディレクティブも同じ問題を抱えています。名前空間('use next.js cache')を付けても、バージョン(@14 vs @15)を表現できず、パラメータを渡す標準的方法もありません。import文がすでに解決している問題を、わざわざ別の形で再現しているのです。
この設計がもたらす具体的な問題
1. 移植性の欠如 - フレームワーク間の移行が困難になります。'use client'は標準のように見えますが、Next.js、Remix、TanStack Startでは意味や実装が異なります(または存在しません)。
2. ツールサポートの限界 - TypeScriptはディレクティブの型チェックができず、ESLintにはフレームワーク固有のルールが必要になります。各バンドラーが独自に実装するため、一貫性が保証されません。
3. デバッグの困難さ - 問題発生時、エラーの発生箇所(クライアント側シリアライゼーション、ネットワーク、サーバー側実行、デシリアライゼーション)を追跡するのが困難になります。従来の明示的なfetch()であればスタックトレースで追えましたが、ディレクティブでは境界が見えなくなります。
第2部:ディレクティブがもたらす課題 ⚠️
デモの罠と隠された複雑さ
Tanner Linsley 氏は「デモは罠である (Demos are a trap)」と指摘しています[5:1]。カンファレンスで紹介されるディレクティブのデモは魅力的ですが、裏側の複雑さを隠蔽しています。
'use server'
async function updateData(formData) {
await db.posts.update(formData)
}
たった3行の裏では、POST エンドポイントの自動生成、シリアライゼーション、CSRF トークン検証、クライアント側スタブ生成、エラーハンドリングなど、多くの処理が自動実行されます。
フレームワークロックインの危険性
Tanner Linsley 氏は、ディレクティブが新しい形のフレームワークロックインを生み出していると指摘しています[1:3]。
ロックインのメカニズム
ディレクティブがロックインを強化する仕組みは、主に以下の要因によるものです。
まず、"use strict" と同じ構文であるため、標準機能だと誤認されやすい点。次に、ファイル全体やモジュールグラフ全体という広範囲に影響する点。さらに、import 文と違ってフレームワークへの依存が暗黙的になってしまう点。
これらの要因が組み合わさることで、将来的に他のフレームワークへ移行しようとした際に、大規模な書き換え(高い移行コスト)が必要となるリスクを生み出しています。
use workflow の議論:ディレクティブ増殖の現実化
2025年10月、Vercel は "use workflow" という新しいディレクティブを発表しました[6]。これは、通常の関数を耐久性のあるワークフローに変換し、自動リトライや状態の永続化を提供するものです。
'use workflow'
export async function aiWorkflow() {
// AI ワークフローの定義
// 自動的に耐久性、リトライ、状態管理が追加される
}
この発表は、開発者コミュニティで大きな反発を招きました。
SST(Serverless Stack)の作者 dax 氏は「コンパイラマジックに依存しているため、これを使うかどうかの判断が非常に難しくなった」と述べています[7]。通常の関数が裏側でどのように変換されるのかが見えず、問題発生時の追跡が困難であることや、Next.js と Vercel のインフラに強く結びついたベンダーロックインの懸念を指摘しています。
Inngest はブログ記事「Explicit APIs vs Magic Directives」[8]で、明示的な API がディレクティブよりも優れている理由を詳述しました。型安全性について「ディレクティブはビルド時に処理されるため、TypeScript がすでにコードをチェックした後になり、同レベルの型安全性を提供できない」と指摘し、デバッグ可能性については「スタックトレースが変換後のコードを指し、ブレークポイントがソースコードと一致せず、自分が書いていないコードをデバッグすることになる」と批判しています。
Upstash も「Vercel Workflow vs Upstash Workflow」[9]という比較記事を公開し、Vercel Workflow は「本番環境で重要な部分がまだ欠けている」と評価しました。特に、障害処理、可観測性、プラットフォームロックインの問題を挙げ、「技術的に精通した開発者向けに、魔法を避けて完全な制御を維持できるよう設計されている」と、明示的な API アプローチの優位性を主張しています。
技術的な比較として、Temporal.io[10] や Cloudflare Workflows[11] といった既存のワークフローエンジンは、明示的な API を提供しています。
// Cloudflare Workflows の明示的なアプローチ
export default {
async fetch(request, env) {
const instance = await env.MY_WORKFLOW.create({
params: { data: "example" }
})
// step.do(), step.sleep() などで明示的に制御
await instance.step.do("task1", async () => {
// タスクの定義
})
}
}
対照的に、"use workflow" は文字列ディレクティブ一つで暗黙的に動作を変更します。
この議論により、ディレクティブの増殖は単なる理論上の懸念ではなく、現実の問題として顕在化しました。コミュニティの懸念は、「Vercel が独自の『疑似標準』を作り出し、エコシステムを自社に囲い込もうとしているのではないか」というものです。
エコシステムの断片化
ディレクティブは「useState(boolean) の罠」
Tanner Linsley 氏は動画[5:2]の中で、ディレクティブの増殖パターンを、React 開発者にとって馴染み深い useState(boolean) の罠に例えています。
// 最初はシンプル
const [isLoading, setIsLoading] = useState(false)
// すぐに増殖し始める
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [isRetrying, setIsRetrying] = useState(false)
const [isValidating, setIsValidating] = useState(false)
// やがて破綻する
// isLoading && isError の場合は?
// isSuccess && isRetrying は矛盾では?
Tanner 氏は、経験豊富な React 開発者なら、このパターンが状態管理の破綻につながることを知っていると指摘します。正しいアプローチは、状態機械(State Machine)やユニオン型を使った明示的な状態管理になります。
// より良いアプローチ
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success', data: Data }
| { status: 'error', error: Error }
const [state, setState] = useState<State>({ status: 'idle' })
そして、ディレクティブも useState(boolean) と同じ増殖パターンを辿っていると述べています。
// 最初はシンプル
'use client'
// すぐに増殖
'use client'
'use server'
'use cache'
'use workflow'
'use no memo'
// 次は何?
'use edge'?
'use worker'?
'use reactive'?
Tanner 氏が指摘する問題は、ディレクティブには型システムによる検証がないことです。useState(boolean) であれば、TypeScript が型エラーで警告してくれる可能性がありますが、ディレクティブは単なる文字列リテラルであり、矛盾する組み合わせを防ぐ仕組みがありません。
各フレームワークが独自のディレクティブを追加し続ければ、エコシステム全体が useState(boolean) の罠を大規模に再現することになる、と Tanner 氏は警鐘を鳴らしています。
デコレータの歴史から学ぶ教訓
Tanner Linsley 氏は、この状況をJavaScript のデコレータ問題に例えています[1:4]。
デコレータの歴史
2015年、TypeScript と Babel が独自のデコレータ実装を提供しました。
// TypeScript/Babel のデコレータ(2015年)
@sealed
class Person {
@readonly
name: string
}
しかし、TC39(JavaScript 標準化委員会)でのデコレータ提案は何度も変更され、最終的に 2022年に Stage 3 に到達した仕様は、TypeScript/Babel の実装とは互換性がありませんでした。
これにより、深刻な事態が発生します。標準仕様が固まる前に TypeScript や Babel の独自実装が広く普及してしまったため、すでに大量に存在していた既存のコードベースが、新しい標準仕様と互換性を持たないものになってしまったのです。
結果として、古い実装と新しい標準が混在するエコシステムの断片化を招き、開発者は膨大な移行コストを支払うか、標準への準拠を諦めるかという難しい選択を迫られることになりました。
ディレクティブも同じ道を辿るのか
ディレクティブも同様のリスクがあります。
// 現在:Next.js のディレクティブ
'use client'
'use server'
// 将来:他のフレームワークが独自のディレクティブを持つ?
'use edge' // エッジランタイム用?
'use worker' // Web Worker 用?
'use reactive' // リアクティブシステム用?
各フレームワークが独自のディレクティブを定義すると、エコシステム全体が分断されます。リンター、型チェッカー、バンドラーなどのツールエコシステムも、各フレームワークのディレクティブを個別にサポートする必要が生じ、この状況は持続可能ではありません。
第3部:明示的なアプローチという選択肢 🎯
TanStack Start の関数ベースアプローチ
TanStack Start は、ディレクティブではなく明示的な関数呼び出しでサーバー機能を実現します。createServerFnで作成したサーバー関数は、ルートローダー内でもReactコンポーネント内でも直接呼び出すことができ、柔軟なデータ取得を可能にしています。
実装例
// サーバー関数の定義
import { createServerFn } from '@tanstack/start'
const getPosts = createServerFn({ method: 'GET' })
.handler(async () => {
// サーバー側でのデータ取得
return await db.posts.findAll()
})
// ルートローダーでの使用(サーバー側で実行)
export const Route = createFileRoute('/posts')({
loader: () => getPosts(), // サーバー側で直接実行
})
// コンポーネントでの使用(クライアント側で実行)
function PostList() {
const getPostsFn = useServerFn(getPosts) // フェッチリクエストに変換
const { data } = useQuery({
queryKey: ['posts'],
queryFn: () => getPostsFn(),
})
return <ul>{data.map(post => <li key={post.id}>{post.title}</li>)}</ul>
}
ルートローダーはサーバー側で実行されるため、サーバー関数を直接呼び出せます。一方、クライアントコンポーネントから呼び出す場合は、useServerFnフックを使用することで、サーバー関数内で発生するリダイレクトやエラーハンドリングなどのサーバーサイドの振る舞いを自動的に処理できます。フックを使わずに直接呼び出した場合、これらの機能は動作しません(サーバーからのレスポンスを受け取るのみとなり、リダイレクトやエラーのフレームワーク連携処理は行われません)。
このアプローチの利点
明示的な import により依存関係が明確になり、TypeScript による完全な型推論、スタックトレースでの追跡が可能です。また、データ取得ロジックをルート定義とコンポーネントの両方で再利用できます。
重要な点として、createServerFn は内部的に "use server" にコンパイルされます(テストコード参照)。つまり、通常の使用では開発者がディレクティブを直接書く必要はありません。Viteプラグインがサーバーコードを抽出し、クライアントバンドルからは除外する処理を自動化します。
フレームワークが抽象化層を提供することで、型推論とバリデーション、ミドルウェアサポート、安定した関数ID生成(SHA256ハッシュ)などの機能を、ディレクティブを意識せずに利用できます。
技術的な詳細:コンパイルプロセスとエッジケース
アロー関数の暗黙的な返り値や、createServerFn のカスタムラッパーを作成する場合など、特定のエッジケースでは、明示的に 'use server' プラグマをハンドラーに記述する必要があることがあります。これはViteプラグインのコード処理方法によるものです。
また、この2段階コンパイルプロセスは2025年11月時点でも継続しており、TanStack Router/Startのコアメンテナーである Manuel Schiller氏は効率化のための最適化を計画していますが、ユーザー向けAPIの使いやすさが優先事項となっています[12]。
フレームワーク選択としての代替
Tanner Linsley 氏が強調するのは透明性(何が起きているか分かりやすいこと)です[1:5]。良いツールとは、何が起こっているか理解でき、問題発生時に追跡でき、依存関係が明示的で、標準との境界が明確なものです。
重要なのは、これは技術的なテクニックではなく、フレームワークそのものの選択だということです。Next.jsを採用する時点で、ディレクティブベースのアプローチを受け入れることになります。「Next.jsを使いながらディレクティブを避ける」という選択肢は App Router を採用する場合は存在しません。
TanStack Start のようなフレームワークは、同じ課題(サーバー/クライアント境界の管理)に対して、異なる設計哲学でアプローチしています。Next.js のエコシステムに深く統合されたプロジェクトの場合、ディレクティブはネイティブな選択肢であり、React Server Componentsとの親和性も高くなります。一方、フレームワーク非依存の設計や、長期的なメンテナンス性・移植性を重視するプロジェクトでは、TanStack Start の createServerFn のような明示的な API が強みを発揮します。
どちらのアプローチにも利点があり、プロジェクトの要件や優先事項によって最適な選択は異なります。開発者コミュニティには、短期的な便利さだけでなく、長期的な保守性、移植性、エコシステム全体への影響を考慮する冷静な視点が求められます。
まとめ
"use client" と "use server" は JavaScript 標準ではありません。あくまでフレームワーク固有の実装です。
ここまで、ディレクティブの懸念点を中心に見てきましたが、これはディレクティブという仕組み自体を「悪」と決めつけたいわけではありません。筆者自身 Next.js を日常的に使っており、特に "use cache" のような機能がもたらすパフォーマンス改善や開発のしやすさは、素直に便利だと感じています。
本記事で問題提起したかったのは、ディレクティブの便利さそのものではなく、「標準化されないディレクティブが、秩序なく増えていくこと」です。"use workflow" のような新しいディレクティブの登場は、Tanner Linsley氏の懸念が現実味を帯びてきた可能性を示しています。
記事中で例えた useState(boolean) の罠や、デコレータのたどった歴史のように、個々の機能は便利でも、それらがバラバラに増え続けることは、将来的にコミュニティ全体の混乱や「技術的負債」につながる危険性があります。
大切なのは、"use cache" のような便利な機能の恩恵を受け入れつつも、これは JavaScript 標準ではないということを忘れないことだと考えています。それを意識しておくことで、移行時の想定外コスト(ロックイン)、デバッグ時の的確な対応、そして突然の仕様変更への備えなど、フレームワーク固有の機能に依存する際に必要な判断を下しやすくなるからです。
以上です!
Discussion