TC39 Signals Proposal
Signals in the Language Itself
Every major framework has independently invented signals: Solid's createSignal, Vue's ref, Angular's signal, Preact's signal, MobX's observable, Svelte 5's $state runes. They all do the same thing with different APIs. The TC39 Signals proposal asks: what if JavaScript had signals built in?
The proposal (Stage 1 as of early 2026) doesn't aim to replace framework reactivity. It aims to provide a shared foundation -- a standard reactive primitive that frameworks build on top of. Think of it like Promise: before ES2015, every library had its own promise implementation. Standardization unified the ecosystem.
// The proposed API
const counter = new Signal.State(0);
const doubled = new Signal.Computed(() => counter.get() * 2);
counter.set(1);
doubled.get(); // 2
The Mental Model
Think of the current framework signal landscape like every country having its own electrical socket type. Your Solid signal adapter doesn't fit in the Vue outlet. A library built for Angular signals needs a different adapter for Preact. The TC39 Signals proposal is like standardizing on a universal socket. Frameworks still build their own appliances (components, templates, renderers), but they all plug into the same power grid (reactive primitives). A reactive library built on Signal.State and Signal.Computed works in every framework without adapters.
Why Standardize?
The fragmentation problem
Today, if you build a reactive state management library, you have to choose:
- Build for Solid's
createSignal? Won't work in Vue. - Build for Vue's
ref? Won't work in Angular. - Build for MobX's
observable? Needs adapters everywhere. - Build framework-agnostic? You'll end up inventing your own signal system.
This fragmentation means:
- Libraries are locked to one framework
- Knowledge doesn't transfer between frameworks
- Performance optimizations must be implemented separately in each framework
- Testing reactive behavior requires framework-specific tooling
The interop opportunity
With standardized signals:
- A state management library built on
Signal.Stateworks in React, Solid, Vue, Angular, Svelte -- everywhere - Framework-agnostic reactive primitives become possible
- Engines can optimize signals at the VM level (V8, SpiderMonkey, JavaScriptCore)
- Testing reactive logic needs no framework dependency
The Proposed API
Signal.State -- Writable Reactive Value
const count = new Signal.State(0);
count.get(); // 0 -- reads the value (tracks if in a reactive context)
count.set(5); // sets to 5 -- notifies subscribers
Signal.State is the writeable signal primitive. It stores a value, tracks readers, and notifies when the value changes. Equality is checked with Object.is by default, with an optional custom equality function:
const position = new Signal.State(
{ x: 0, y: 0 },
{
equals: (a, b) => a.x === b.x && a.y === b.y
}
);
Signal.Computed -- Derived Value
const firstName = new Signal.State('Alice');
const lastName = new Signal.State('Smith');
const fullName = new Signal.Computed(() => {
return `${firstName.get()} ${lastName.get()}`;
});
fullName.get(); // 'Alice Smith' -- lazy, cached, auto-tracked
Signal.Computed is the standard computed/memo primitive. It's lazy (only evaluates when read), cached (returns the previous value if dependencies haven't changed), and automatically tracks dependencies via the same tracking context pattern used by every signal system.
Computed values are read-only. There's no set method.
Signal.subtle.Watcher -- The Effect Bridge
Here's where it gets interesting. The proposal deliberately does NOT include an effect primitive. Instead, it provides Signal.subtle.Watcher -- a low-level API for frameworks to build their own effect systems:
const watcher = new Signal.subtle.Watcher(() => {
// Called when any watched signal becomes dirty
// This is the "notification" callback
scheduleRerender();
});
const count = new Signal.State(0);
const doubled = new Signal.Computed(() => count.get() * 2);
watcher.watch(doubled); // Watch this computed for changes
count.set(5); // Watcher's callback fires: doubled is now dirty
The subtle namespace is intentional. Like crypto.subtle, it signals that this API is low-level and primarily for framework/library authors, not application developers. Application code would use framework-provided effect APIs that build on top of Signal.subtle.Watcher.
Why no built-in effect()?
The proposal authors deliberately excluded effect() because different frameworks have wildly different requirements for effects:
- Solid effects run synchronously during component setup
- Vue batches effects and runs them asynchronously after the current tick
- Angular ties effects to injection contexts and component lifecycles
- React defers effects to after paint (useEffect) or synchronously before paint (useLayoutEffect)
A one-size-fits-all effect() would satisfy none of these. Instead, Signal.subtle.Watcher provides the notification mechanism, and each framework wraps it with their own scheduling and lifecycle semantics.
Design Decisions
Pull-based evaluation
The proposal uses pull-based (lazy) evaluation for computed values. When a dependency changes, the computed marks itself as potentially dirty but doesn't recalculate. Recalculation happens when get() is called.
This has a subtle implication: the Watcher callback fires when a dependency changes, but the new value isn't yet computed. The framework must call get() to pull the new value, at which point the computed recalculates. This allows frameworks to schedule the pull at the optimal time (during rendering, during idle time, etc.).
Glitch-free by design
The proposal guarantees glitch-free reads. When you call get() on a computed, all of its transitive dependencies are evaluated in topological order before the computed's own derivation runs. You always see a consistent snapshot.
const a = new Signal.State(1);
const b = new Signal.Computed(() => a.get() * 2);
const c = new Signal.Computed(() => a.get() + b.get());
a.set(2);
c.get(); // Always 6 (2 + 4), never 4 (2 + 2 with stale b)
No batching primitive (yet)
The current proposal doesn't include a batch() function. Batching is left to frameworks. The Watcher callback fires once per dependency change, and frameworks are expected to coalesce notifications through their own scheduling:
const watcher = new Signal.subtle.Watcher(() => {
// Framework coalesces: schedule one update, not many
if (!updateScheduled) {
updateScheduled = true;
queueMicrotask(() => {
updateScheduled = false;
performUpdate();
});
}
});
The Polyfill Ecosystem
The TC39 proposal authors have built production-grade polyfills to validate the design before advancing stages. These polyfills are already competitive with hand-rolled signal implementations:
// Using the signal-polyfill package
import { Signal } from 'signal-polyfill';
const count = new Signal.State(0);
const doubled = new Signal.Computed(() => count.get() * 2);
Multiple frameworks have begun prototyping integrations:
- Angular has explored aligning its
signal()withSignal.Statesemantics - Vue's alien signals library was designed with TC39 interop in mind
- Solid 2.0's
@solidjs/signalspackage follows the same reactive semantics
The proposal is designed with input from the authors/maintainers of Angular, Bubble, Ember, FAST, MobX, Preact, Qwik, RxJS, Solid, Starbeam, Svelte, Vue, and Wiz. This broad collaboration is unusual for TC39 proposals and increases the likelihood of eventual adoption.
Timeline and Expectations
The proposal is at Stage 1 (as of early 2026), which in TC39 terms means:
- The committee agrees the problem is worth solving
- The proposed solution is being explored but is not final
- Significant design changes are still possible
The deliberate strategy is to do extensive prototyping and real-world validation before advancing. The authors want to prove that standardized signals work well when integrated into multiple frameworks before asking the committee to lock down the API. This is a slower path to standardization but a more likely path to a good standard.
Realistic timeline expectations:
- Stage 2 (specification draft): earliest 2026-2027, contingent on successful framework integrations
- Stage 3 (candidate, ready for implementation): 2027-2028 at the earliest
- Stage 4 (finished, shipping in engines): 2028+ at the earliest
Engine-level optimization potential
If signals reach Stage 4 and ship in engines, V8/SpiderMonkey/JavaScriptCore could optimize them at the VM level:
- Inline caching for signal reads in hot loops
- Hidden classes for signal objects (predictable shapes for JIT optimization)
- GC optimization for dependency graph management (weak references, finalizers)
- Potential hardware-level reactivity tracking (speculative, long-term)
These optimizations are impossible for userland signal implementations. A framework's signal() is just JavaScript objects and closures -- the engine can't know the reactive semantics. A native Signal.State tells the engine exactly what it's looking at.
How the TC39 Signals proposal differs from existing implementations
The TC39 proposal makes several deliberate design choices that differ from existing frameworks:
No deep reactivity: Signal.State wraps a single value. If you store an object, only the reference is tracked, not nested properties. Deep reactivity (Vue's reactive(), Solid's stores) is out of scope. Frameworks that need it build it on top.
No automatic effect disposal: Signal.subtle.Watcher doesn't auto-dispose. Framework lifecycles handle cleanup. This avoids baking in any specific component model.
No synchronous notification guarantee: The Watcher callback is called synchronously when a watched signal becomes dirty, but the proposal doesn't guarantee when. This gives engines freedom to batch or defer notifications for performance.
Computed purity assumption: Signal.Computed derivation functions should be pure (no side effects). The proposal doesn't enforce this, but the semantics assume it. Side effects in computeds can lead to unpredictable behavior because computeds are lazy and may not execute when you expect.
These constraints keep the proposal minimal and broadly useful. Anything more specific would favor some frameworks over others.
What Changes If This Ships
If TC39 Signals reaches Stage 4 and ships in browsers:
- Framework interop: A reactive state management library works across React, Solid, Vue, Angular, and Svelte without adapters
- Smaller bundles: Frameworks drop their own reactive runtimes (~2-8KB each) in favor of the native implementation
- Shared tooling: DevTools, testing utilities, and debugging tools for signals work across all frameworks
- Performance floor: Engine-optimized signals are faster than any userland implementation
- Curriculum simplification: "Learn signals once, use everywhere" becomes reality
The biggest winner would be the library ecosystem. Today, a library like Zustand is React-specific because it's built on React's subscription model. A library built on Signal.State would work everywhere signals are supported.
- 1TC39 Signals (Stage 1) proposes Signal.State (writable) and Signal.Computed (derived) as standardized primitives
- 2The proposal deliberately excludes effect() -- frameworks build their own effect semantics on Signal.subtle.Watcher
- 3The design prioritizes framework interop: Angular, Vue, Solid, Preact, Svelte, and more all contributed to the proposal
- 4Realistic timeline: Stage 3+ around 2027-2028 at the earliest, with production use through polyfills available now
- 5Engine-level optimization potential (inline caching, GC awareness) is impossible for userland signal implementations
| What developers do | What they should do |
|---|---|
| Assuming TC39 Signals will replace framework-specific reactivity APIs The proposal is a low-level primitive. Frameworks add scheduling, lifecycle integration, template compilation, and developer experience on top | TC39 Signals provide a foundation that frameworks build on top of -- framework APIs will still exist with their own ergonomics |
| Waiting for the TC39 proposal before learning signals The TC39 API is based on the same three-primitive model (state, computed, watcher) that every framework uses. Learning signals in Solid or Vue teaches you the TC39 model | Learn signals now through any framework -- the concepts are universal and will transfer directly to the standard |
| Using Signal.Computed for side effects Computeds are lazy -- they only execute when read. A side effect inside a computed may never run, or may run at unexpected times | Signal.Computed should be pure. Use framework-specific effect APIs for side effects |