Commands and Platform Boundaries | TanStack Blog
/ 9 min read /
Table of Contents 目录
Directives and the Platform Boundary | TanStack Blog
URL: https://tanstack.com/blog/directives-and-the-platform-boundary
Published: October 24, 2025
Author: Tanner Linsley
Translation: Qwen 3 Max
A Quiet Trend Emerging in the JavaScript Ecosystem
For years, JavaScript had exactly one meaningful directive: "use strict". It was standardized, enforced by the runtime, and behaved consistently across all environments. It represented a clear contract between the language, the engine, and the developer.
But today, we’re witnessing a new trend: frameworks are inventing their own top-level directives. "use client", "use server", "use cache", "use workflow"… these are popping up all over the ecosystem. They look like language features, appear where language features appear, and affect how code is parsed, bundled, and executed.
The critical difference, though: these are not standardized JavaScript features. Runtimes don’t understand them, there’s no unified spec governing them, and every framework is free to define its own meaning, rules, and edge cases.
This might feel convenient in the short term, but in the long run it compounds confusion, makes debugging harder, and adds burden to tooling and code portability — lessons we’ve learned before.
When Directives Look Like Platform Features, Developers Treat Them as Such
A directive at the top of a file carries an air of authority, giving the impression of “this is a language-level fact” rather than “this is a framework hint.” That illusion creates a cascade of problems:
- Developers mistakenly believe these directives are official standards;
- The ecosystem starts to treat them as a shared API surface;
- Newcomers struggle to tell the difference between JavaScript itself and framework “magic”;
- The boundary between platform and vendor blurs;
- Debugging experience degrades, and tooling must special-case each behavior.
We’re already seeing this confusion. Many developers today think "use client" and "use server" are just how modern JavaScript works, not realizing they only make sense under specific build pipelines and server component semantics. This misconception reveals a deeper problem.
Credit Where Due: use server and use client
Some directives exist because multiple tools need a simple, unified coordination point. In practice, "use server" and "use client" serve as pragmatic shims that tell bundlers and runtimes where code is allowed to execute in React Server Components (RSC) environments. Because their scope is well-defined (execution location only), they’ve gained relatively broad support across multiple bundlers.
Even so, their limitations quickly show once real-world complexity arrives. In large applications, we often need parameters and strategies tied to correctness and security: HTTP methods, request headers, middleware, authentication context, tracing information, caching behavior, and more. Directives have no natural way to carry these options, so such information often gets ignored, handled separately, or re-encoded via derivative directive variants.
When Directives Start to Struggle: Options and “Directive Peripheral” APIs
When a directive soon after creation needs configuration options, or spawns sibling directives like "use cache:remote" and helper functions like cacheLife(...), it’s usually a sign that the feature should have been an API, not a string at the top of a file. Since you end up needing a function anyway, just use functions all the way.
For example:
"use cache:remote";const fn = () => "value";vs an explicit API with source and options:
// Explicit API with source and configuration optionsimport { cache } from "next/cache";export const fn = cache(() => "value", { strategy: "remote", ttl: 60,});For server behaviors that need detailed configuration:
import { server } from "@acme/runtime";
export const action = server( async (req) => { return new Response("ok"); }, { method: "POST", headers: { "x-foo": "bar" }, middleware: [requireAuth()], },);APIs have provenance (via import), versioning (via package management), composability (via functions), and testability. Directives typically lack these properties, and forcing options into directives often smells like a design antipattern.
Shared Syntax Without Shared Spec Becomes a Fragile Foundation
Once multiple frameworks start adopting directives, we land in the worst-case scenario:
| Category | Shared Syntax | Shared Contract | Result |
|---|---|---|---|
| ECMAScript | ✅ | ✅ | Stable and universal |
| Framework API | ❌ | ❌ | Isolated but harmless |
| Framework Directive | ✅ | ❌ | Confusing and unstable |
Sharing syntax without a unified definition leads to:
- Semantic drift: Each framework defines semantics independently;
- Portability issues: Code looks generic but isn’t;
- Tooling burden: Bundlers, linters, and IDEs must guess or trace behavior;
- Platform friction: Standards bodies get “held hostage” by ecosystem expectations, making it hard to move forward.
We’ve been through a similar ordeal with decorators: TypeScript pushed non-standard semantics, the community widely adopted them, but TC39 went in a different direction — and many are still painfully migrating.
”Isn’t This Just a Babel Plugin or Macro in Different Syntax?”
Functionally, yes. Directives and custom transforms both modify behavior at compile time. The issue isn’t about capability — it’s about surface form and perception.
- Directives look like platform features: No import, no explicit ownership, no visible source — they give the illusion that “this is JavaScript.”
- APIs/macros point to a clear owner: Imports provide provenance, versioning, and discoverability.
At best, a directive is equivalent to calling a global, import-less function at the top of a file, like window.useCache(). And that’s exactly the risk: it hides the provider, camouflaging framework semantics as the language itself.
For example:
"use cache";const fn = () => "value";vs explicit API:
// Explicit API (traceable, attributable, discoverable)import { createServerFn } from "@acme/runtime";export const fn = createServerFn(() => "value");or a global “magic”:
// Global magic (no import, provider hidden)window.useCache();const fn = () => "value";Why does this matter?
- Attribution and provenance:
importtells you who supplies the behavior; directives don’t; - Tooling friendliness: APIs live in the package namespace; directives require the whole ecosystem to special-case them;
- Portability and migration cost: Replacing an imported API is straightforward; cleaning up directive semantics scattered across files is expensive and ambiguous;
- Education and expectation management: Directives blur the platform boundary; APIs keep it clear.
So while a Babel plugin or macro could achieve the same thing, an import-based API clearly keeps it within “framework space.” Directives, on the other hand, move that same behavior into the illusion of “language space” — and that’s the core concern of this post.
”Can Namespacing Fix It?” (e.g. “use next.js cache”)
Namespacing helps humans identify things, but doesn’t solve the root problem:
- It still looks like a platform feature: Top-level string literals imply language, not library;
- It still lacks module-level provenance and versioning: Imports encode both; strings don’t;
- It still requires tooling (bundlers, linters, IDEs) to special-case, instead of leveraging standard import resolution;
- It still pushes for “pseudo-standardization” without a spec, just with a vendor prefix;
- It still incurs higher migration cost compared to replacing an import API.
For example:
"use next.js cache";const fn = () => "value";vs explicit API:
// Explicit, attributable API with provenance and versioningimport { cache } from "next/cache";export const fn = cache(() => "value");If the goal is to make provenance clear, import already provides a clean solution that works with the existing ecosystem. If the goal is a cross-framework shared primitive, then you need a real spec, not vendor strings that look like syntax.
Directives Can Create Competitive Dynamics
Once directives become a competitive differentiator, incentives shift:
- A vendor introduces a new directive;
- It becomes a visible feature;
- Developers expect it everywhere;
- Other frameworks are forced to follow;
- Syntax spreads without a spec.
And so we see:
'use server''use client''use cache''use cache:remote''use workflow''use streaming''use edge'Even runtime semantics like persistent tasks, caching strategies, and execution location are being encoded as directives. These should be capability models, not syntax models. Encoding them as directives is equivalent to setting direction outside the standards process, and that’s worth wary.
For Features Needing Rich Options, Prefer APIs Over Directives
Persistent execution (e.g., "use workflow", "use step") is one such case, but the principle is general: directives can only reduce behavior to a boolean, while many features need options and room to evolve. Compilers and transforms can support either form; the point is to choose the right form for long-term clarity and maintainability.
For example:
'use workflow''use step'Alternative: use explicit APIs with provenance and options:
import { workflow, step } from "@workflows/workflow";
export const sendEmail = workflow( async (input) => { /* ... */ }, { retries: 3, timeout: "1m" },);
export const handle = step( "fetchUser", async () => { /* ... */ }, { cache: 60 },);The function form is equally amenable to AST analysis and compiler transforms, while having provenance (import) and type safety.
Another approach is to inject a global object once and add types for it:
// One-time initializationglobalThis.workflow = createWorkflow();// Global type declaration (e.g., global.d.ts)declare global { var workflow: typeof import('@workflows/workflow').workflow}Usage still stays as an API, no directive needed:
export const task = workflow( async () => { /* ... */ }, { retries: 5 },);Using compilers to improve developer experience is great — JSX is a successful example! But we must be responsible: extend the platform via APIs with clear provenance and types, not via top-level strings that look like the language itself. These are suggestions, not dogma.
Silent Lock-In Can Form Quietly
Even without ill intent, directives are naturally lock-in prone:
- Cognitive lock-in: Developers build muscle memory for a vendor’s directive semantics;
- Tooling lock-in: IDEs, bundlers, and compilers must target specific runtimes;
- Code lock-in: Directives live at the syntax level, making removal or migration expensive.
Directives might not look like proprietary features, but their reshaping of ecosystem syntax makes them more “proprietary” than ordinary APIs.
If You Really Want to Build Shared Primitives, Collaborate on Specs and APIs
There are real problems to solve:
- Server execution boundaries
- Streaming and async workflows
- Distributed runtime primitives
- Persistent tasks
- Caching semantics
But these should be solved through APIs, capability models, and future standards, not through bundler-driven, non-spec “pseudo-syntax.”
If multiple frameworks genuinely want to build shared primitives, the responsible path is:
- Collaborate cross-framework on a specification;
- Propose to TC39 at the appropriate time;
- Keep non-standard features clearly within API space, not language space.
Directives should be rare, stable, standardized, and used cautiously — not proliferated across vendors.
Why This Is Different from the JSX / Virtual DOM Era
One might compare criticism of directives to early skepticism about React JSX or virtual DOM. But the failure modes are different. JSX and VDOM never pretended to be language features — they always came with explicit imports, clear provenance, and well-defined tooling boundaries. Directives, on the other hand, sit at the top of files, look like platform features, and create ecosystem expectations and tooling burdens without a shared spec.
Summary
Framework directives may offer “developer experience magic” today, but the current trend points toward a more fragmented future — an ecosystem split by tool-defined “dialects” instead of unified by standards.
We can strive for clearer boundaries.
Frameworks should innovate, but they should also clearly distinguish framework behavior from platform semantics, rather than blurring that line for short-term adoption. Clear boundaries benefit the entire ecosystem.