iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🚨

"use client" is Not a JavaScript Standard: The Risks Hidden Behind "Source-Unknown" Strings

に公開

Introduction

Directives like "use client" and "use server" have become widely used with the spread of React Server Components (RSC). At first glance, they might appear similar to standard JavaScript features such as "use strict", but in reality, they are fundamentally different.

Tanner Linsley, the creator of TanStack, has raised a warning in his blog post[1] that these directives are creating a new form of framework lock-in. While directives improve developer experience, they are non-standard proprietary specifications that carry long-term risks, such as ecosystem fragmentation and difficulties in migrating to other frameworks.

Based on Tanner Linsley's blog post and related discussions, this article will delve into the following topics:

  • What are directives and why are they problematic
  • The risk of blurring the line between "standard technology" and "proprietary features"
  • Framework lock-in and ecosystem fragmentation
  • The option of an explicit approach

Part 1: Fundamentals of Directives and the Core Problem 🔍

What are Directives

In JavaScript, a directive is a string literal that instructs a specific behavior. The most famous example is "use strict".

"use strict";

// Strict mode is enabled
x = 3.14; // Error: Variable declaration is required

"use strict" was standardized in ECMAScript 5 (2009), and all JavaScript runtimes (Node.js, browsers, etc.) understand it and guarantee the same behavior.

Directives in the RSC Ecosystem

React Server Components use directives with different roles: "use client" and "use server".

// Server Component (default)
async function BlogPost() {
  const post = await fetchPost()
  return <article>{post.content}</article>
}

// Client Component
'use client'

function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

// Server Action
'use server'

async function updatePost(formData) {
  await db.posts.update(formData)
}

These directives define the boundary between server and client, instructing bundlers as follows:

  • "use client" — Include this module and its dependencies in the client bundle.
  • "use server" — Make this function executable only on the server side and generate a POST endpoint.

And in the last two years or so, the number of directives has rapidly increased.

Directive Provider Purpose Status
"use client" React/Next.js Client Component Stable
"use server" React/Next.js Server Action Stable
"use cache" Next.js 16 Caching Stable[2]
"use workflow" Vercel AI Workflow Announced
"use memo" React Compiler Enable memoization Officially documented[3]
"use no memo" React Compiler Disable memoization Officially documented[4]

All of these are proprietary specifications, not standards of JavaScript or TypeScript.

How it Works in Detail

Directives are interpreted not by the JavaScript runtime, but by build tools (such as Next.js's Turbopack or Webpack). Based on directives, the bundler distributes code as follows:

  1. "use client" — Include in the client bundle.
  2. No directive — Server-only (do not include in the client bundle).

The server sends client components as "module references", and the actual code is executed on the client side.

// Executed on the server side
async function ServerComponent() {
  const data = await fetchData() // Executed only on the server side
  return <ClientPart initialData={data} /> {/* Module reference */}
}

Platform and Framework Boundaries

Tanner Linsley takes issue with the concept of Platform Boundary. Here, "platform" refers to a standardized foundation with common specifications across multiple implementations.

Type Description Example
Platform
(Standard)
ECMAScript, Web standards, etc.
Specifications exist, same behavior across multiple implementations
"use strict", fetch, DOM API
Framework
(Implementation)
Specific implementation, proprietary conventions/APIs Next.js's cookies(), Remix's loader

Traditionally, framework-specific features were provided via explicit import statements, allowing developers to immediately recognize "this is Next.js specific" by seeing imports like next/headers.

However, directives blur this boundary. "use client" looks like "use strict", but it does not exist in the JavaScript specification; it is a proprietary specification interpreted by the bundler (Turbopack/Webpack in the case of Next.js). Since there is no explicit marker like an import statement, the boundary between platform (standard) and framework (implementation) becomes ambiguous.

Why Directives Blur the Boundary

Tanner Linsley uses a comparison with JSX and an analogy to global functions to explain the problem with directives[1:1][5].

JSX Had "Provenance"

JSX also initially seemed like "magic," but it had clear boundaries.

// JSX - Clear provenance
// Filename: Counter.tsx  ← Dedicated extension
import React from 'react'  // ← Explicit import

function Counter() {
  return <button>Click</button>
}
  • Dedicated extensions like .jsx/.tsx
  • Clear conversion specification to React.createElement()
  • Explicit toolchain of Babel/TypeScript

Directives, on the other hand, lack provenance.

// Directives - Unclear provenance
'use client'  // ← Where did it come from? What does it depend on?

import { useState } from 'react'
function Counter() { /* ... */ }

It's just a string at the top of the file, using the regular .tsx extension, and no import is needed. It's unclear which bundler, which framework, or which version it depends on.

Directives are "Global Functions Without Imports"

Tanner Linsley likens directives to global functions[1:2].

// Directive
'use cache'
const fn = () => 'value'

// ≈ Global function (problematic)
window.useCache()
const fn = () => 'value'

// In contrast, explicit API (recommended)
import { cache } from 'next/cache'  // ← Clear provenance, version control possible
export const fn = cache(() => 'value')

Problems with global functions:

  • Unclear origin
  • Cannot specify version
  • Difficult to mock or replace
  • Limited support from TypeScript, ESLint, IDEs

Directives suffer from the same problems. Even with a namespace ('use next.js cache'), they cannot express versions (@14 vs @15), nor is there a standard way to pass parameters. They are recreating a problem that import statements have already solved.

Specific Problems This Design Causes

1. Lack of Portability - Makes migration between frameworks difficult. "use client" may look like a standard, but its meaning and implementation differ (or it doesn't exist) in Next.js, Remix, and TanStack Start.

2. Limitations of Tool Support - TypeScript cannot type-check directives, and ESLint requires framework-specific rules. Since each bundler implements them independently, consistency is not guaranteed.

3. Difficulty in Debugging - When problems occur, it becomes difficult to trace the source of the error (client-side serialization, network, server-side execution, deserialization). With traditional, explicit fetch() calls, errors could be traced via stack traces, but directives obscure these boundaries.

Part 2: Challenges Posed by Directives ⚠️

The Trap of Demos and Hidden Complexity

Tanner Linsley points out that "Demos are a trap"[5:1]. While directive demos presented at conferences are appealing, they conceal underlying complexities.

'use server'
async function updateData(formData) {
  await db.posts.update(formData)
}

Behind these mere three lines, many processes are automatically executed: automatic generation of POST endpoints, serialization, CSRF token validation, client-side stub generation, error handling, and more.

The Danger of Framework Lock-in

Tanner Linsley points out that directives are creating a new form of framework lock-in[1:3].

Mechanism of Lock-in

The mechanism by which directives strengthen lock-in is primarily due to the following factors:

First, the fact that they share the same syntax as "use strict" makes them easily mistaken for standard features. Second, they affect a wide range—the entire file or module graph. Furthermore, unlike import statements, dependency on the framework becomes implicit.

These factors combined create a risk that, in the future, migrating to another framework will require extensive rewriting (high migration cost).

The "use workflow" Debate: Directive Proliferation Becoming a Reality

In October 2025, Vercel announced a new directive called "use workflow"[6]. This converts ordinary functions into durable workflows, providing automatic retries and state persistence.

'use workflow'

export async function aiWorkflow() {
  // AI workflow definition
  // Durability, retries, and state management are automatically added
}

This announcement met with significant backlash from the developer community.

Dax, the creator of SST (Serverless Stack), commented that "it becomes very difficult to decide whether to use this due to its reliance on compiler magic"[7]. He highlighted concerns about the difficulty of tracing issues when ordinary functions are transformed behind the scenes, and the vendor lock-in tied strongly to Next.js and Vercel's infrastructure.

Inngest detailed why explicit APIs are superior to magic directives in their blog post "Explicit APIs vs Magic Directives"[8]. Regarding type safety, they stated, "Directives are processed at build time, after TypeScript has already checked the code, and thus cannot provide the same level of type safety." For debuggability, they criticized that "stack traces point to transformed code, breakpoints don't match source code, and you end up debugging code you didn't write."

Upstash also published a comparison article, "Vercel Workflow vs Upstash Workflow"[9], evaluating Vercel Workflow as "still missing crucial pieces for production." They specifically cited issues with fault tolerance, observability, and platform lock-in, asserting the superiority of an explicit API approach: "designed for technically astute developers to avoid magic and maintain full control."

For technical comparison, existing workflow engines like Temporal.io[10] and Cloudflare Workflows[11] provide explicit APIs.

// Cloudflare Workflows' explicit approach
export default {
  async fetch(request, env) {
    const instance = await env.MY_WORKFLOW.create({
      params: { data: "example" }
    })

    // Explicitly control with step.do(), step.sleep(), etc.
    await instance.step.do("task1", async () => {
      // Task definition
    })
  }
}

In contrast, "use workflow" implicitly alters behavior with a single string directive.

This discussion revealed that the proliferation of directives is not merely a theoretical concern but a manifest real-world problem. The community's concern is whether Vercel is trying to create its own "pseudo-standards" and enclose the ecosystem within its own sphere.

Ecosystem Fragmentation

Directives are the "useState(boolean) Trap"

In his video[5:2], Tanner Linsley likens the pattern of directive proliferation to the useState(boolean) trap, familiar to React developers.

// Starts simple
const [isLoading, setIsLoading] = useState(false)

// Soon proliferates
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)

// Eventually breaks down
// What if isLoading && isError?
// Is isSuccess && isRetrying a contradiction?

Tanner points out that experienced React developers know this pattern leads to state management breakdown. The correct approach is explicit state management using state machines or union types.

// Better approach
type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success', data: Data }
  | { status: 'error', error: Error }

const [state, setState] = useState<State>({ status: 'idle' })

And he states that directives are following the same proliferation pattern as useState(boolean).

// Starts simple
'use client'

// Soon proliferates
'use client'
'use server'
'use cache'
'use workflow'
'use no memo'

// What's next?
'use edge'?
'use worker'?
'use reactive'?

The problem Tanner highlights is that directives lack validation by a type system. While TypeScript might warn of type errors for useState(boolean), directives are mere string literals, lacking a mechanism to prevent contradictory combinations.

If each framework continues to add its own directives, the entire ecosystem will reproduce the useState(boolean) trap on a massive scale, Tanner warns.

Lessons from the History of Decorators

Tanner Linsley compares this situation to the JavaScript decorator problem[1:4].

History of Decorators

In 2015, TypeScript and Babel provided their own decorator implementations.

// TypeScript/Babel decorators (2015)
@sealed
class Person {
  @readonly
  name: string
}

However, the decorator proposal in TC39 (JavaScript Standardization Committee) underwent numerous changes, and the specification that finally reached Stage 3 in 2022 was incompatible with TypeScript/Babel's implementations.

This led to a serious situation. Because TypeScript and Babel's proprietary implementations became widely adopted before a standard specification was finalized, existing codebases, which were already massive, became incompatible with the new standard specification.

As a result, it caused fragmentation of the ecosystem, where old implementations and new standards coexisted, forcing developers into a difficult choice: either incur enormous migration costs or abandon compliance with the standard.

Are Directives Following the Same Path?

Directives carry a similar risk.

// Current: Next.js directives
'use client'
'use server'

// Future: Other frameworks might have their own directives?
'use edge'      // For edge runtimes?
'use worker'    // For Web Workers?
'use reactive'  // For reactive systems?

If each framework defines its own directives, the entire ecosystem will become fragmented. The tool ecosystem—linters, type checkers, bundlers—will also need to individually support each framework's directives, and this situation is not sustainable.

Part 3: Explicit Approach as an Option 🎯

TanStack Start's Function-Based Approach

TanStack Start achieves server-side functionality not with directives, but with explicit function calls. A server function created with createServerFn can be called directly within a route loader or a React component, enabling flexible data fetching.

Implementation Example

// Server function definition
import { createServerFn } from '@tanstack/start'

const getPosts = createServerFn({ method: 'GET' })
  .handler(async () => {
    // Data fetching on the server side
    return await db.posts.findAll()
  })

// Usage in a route loader (executed on the server side)
export const Route = createFileRoute('/posts')({
  loader: () => getPosts(), // Executed directly on the server side
})

// Usage in a component (executed on the client side)
function PostList() {
  const getPostsFn = useServerFn(getPosts) // Transformed into a fetch request
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: () => getPostsFn(),
  })

  return <ul>{data.map(post => <li key={post.id}>{post.title}</li>)}</ul>
}

Route loaders are executed on the server side, so server functions can be called directly. On the other hand, when called from a client component, the useServerFn hook automatically handles server-side behaviors such as redirects and error handling that occur within the server function. If called directly without the hook, these features will not work (it will only receive a response from the server, and framework integration for redirects or errors will not occur).

Advantages of this Approach

Explicit import statements clarify dependencies, enabling full type inference by TypeScript and traceable stack traces. Data fetching logic can also be reused in both route definitions and components.

Crucially, createServerFn is internally compiled to "use server" (see test code). This means that developers typically don't need to write directives directly. The Vite plugin automates the process of extracting server code and excluding it from the client bundle.

By providing an abstraction layer, the framework offers features like type inference and validation, middleware support, and stable function ID generation (SHA256 hash), all without requiring developers to be aware of directives.

Technical Details: Compilation Process and Edge Cases

In certain edge cases, such as implicit return values for arrow functions or creating custom wrappers for createServerFn, it may be necessary to explicitly write the 'use server' pragma in the handler. This is due to how the Vite plugin processes code.

This two-stage compilation process is still ongoing as of November 2025, and Manuel Schiller, a core maintainer of TanStack Router/Start, plans optimizations for efficiency, but user-facing API usability remains a priority[12].

Alternative as a Framework Choice

Tanner Linsley emphasizes transparency (that it's easy to understand what's happening)[1:5]. A good tool is one that allows you to understand what's happening, trace problems when they occur, makes dependencies explicit, and has clear boundaries with standards.

What's important is that this is not a technical trick, but a framework choice itself. By adopting Next.js, you accept a directive-based approach. The option to "avoid directives while using Next.js" does not exist if you adopt the App Router.

Frameworks like TanStack Start approach the same problem (managing server/client boundaries) with a different design philosophy. For projects deeply integrated into the Next.js ecosystem, directives are a native choice and have high affinity with React Server Components. On the other hand, for projects that prioritize framework-agnostic design, long-term maintainability, and portability, explicit APIs like TanStack Start's createServerFn offer a strong advantage.

Both approaches have benefits, and the optimal choice depends on project requirements and priorities. The developer community needs a calm perspective that considers not only short-term convenience but also long-term maintainability, portability, and the impact on the entire ecosystem.

Summary

"use client" and "use server" are not JavaScript standards. They are merely framework-specific implementations.

While this article has focused on the concerns regarding directives, it's not to label directives themselves as "evil." I personally use Next.js daily, and I genuinely find the performance improvements and ease of development brought by features like "use cache" to be convenient.

What this article aimed to highlight was not the convenience of directives themselves, but rather the danger of "non-standardized directives proliferating without order." The emergence of new directives like "use workflow" suggests that Tanner Linsley's concerns may be becoming a reality.

As illustrated by the useState(boolean) trap and the history of decorators in the article, while individual features may be convenient, their fragmented proliferation carries the risk of leading to overall confusion and "technical debt" for the community in the future.

It is crucial to embrace the benefits of convenient features like "use cache" while remembering that they are not JavaScript standards. Being aware of this makes it easier to make necessary judgments when relying on framework-specific features, such as anticipating unexpected costs during migration (lock-in), precise responses during debugging, and preparing for sudden specification changes.

That's all!

脚注
  1. Directives and the Platform Boundary - TanStack Blog ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  2. Directives: use cache - Next.js ↩︎

  3. 'use memo' directive - React ↩︎

  4. 'use no memo' directive - React ↩︎

  5. Server Components Are Not The Future - Tanner Linsley & Theo - YouTube ↩︎ ↩︎ ↩︎

  6. Vercel Workflow - Vercel Documentation ↩︎

  7. dax (@thdxr), X (Twitter) ↩︎

  8. Explicit APIs vs Magic Directives - Inngest Blog ↩︎

  9. Vercel Workflow vs Upstash Workflow - Upstash Blog ↩︎

  10. Temporal Workflows - Temporal Documentation ↩︎

  11. Cloudflare Workflows - Cloudflare Documentation ↩︎

  12. Manuel Schiller on createServerFn compilation - X (Twitter) ↩︎

Discussion