Angular, Preact, Solid, and Vue already use them. Now Signals are a TC39 proposal heading toward native JavaScript. Here’s what they are, how they work, and why they matter.
Every major JavaScript UI framework invented its own version of the same idea. Angular has signals. Vue has ref and reactive. Solid is built entirely on signals. Preact has signal. MobX has observables. Svelte has $state. They look different, they have different APIs, and they can’t interoperate — but they solve exactly the same problem in exactly the same way.
That convergence is not a coincidence. It’s evidence that the JavaScript language is missing a primitive. TC39, the committee that evolves JavaScript, noticed. The result is the Signals proposal — a collaboration between the authors and maintainers of Angular, Bubble, Ember, FAST, MobX, Preact, Qwik, RxJS, Solid, Starbeam, Svelte, Vue, and Wiz — all working toward a common standard that all of them can build on.
This post explains what signals are, how the TC39 proposal works, what the API looks like, and why this matters for the entire JavaScript ecosystem.
What Problem Do Signals Solve?
Before understanding signals, it helps to understand the problem they solve with precision.
In any interactive UI, state changes over time. When state changes, the parts of the UI that depend on that state need to update. The question every framework has to answer is: how does a change in state propagate to the code that needs to react to it?
The naive answer is polling — check state every frame and update if something changed. That’s expensive. The naive reactive answer is to subscribe explicitly — every time you read a value, register a callback for when it changes. That’s verbose and error-prone.
Signals are a middle path: automatically tracked reactive state. When you read a signal’s value inside a reactive context (a computed value or an effect), the runtime automatically records that dependency. When the signal changes, only the computations that actually depend on it are re-evaluated — nothing more, nothing less.
This is what every major framework has been doing internally for years. The TC39 proposal just standardises it.
The Proposal: Status and Champions
The Signals proposal was presented to TC39 in April 2024, seeking Stage 1. The current draft is based on design input from the authors and maintainers of Angular, Bubble, Ember, FAST, MobX, Preact, Qwik, RxJS, Solid, Starbeam, Svelte, Vue, Wiz, and more.
The proposal champions include Daniel Ehrenberg, Yehuda Katz, Jatin Ramanathan, Shay Lewis, Kristen Hewell Garrett, Dominic Gannaway, Preston Sego, and Rob Eisenberg — the original co-authors.
The TC39 process has four stages before a proposal becomes standard JavaScript:
Stage 0 → Strawperson (anyone can propose)
Stage 1 → Proposal (TC39 takes it seriously, investigation begins)
Stage 2 → Draft (spec text being written)
Stage 3 → Candidate (ready for implementation feedback)
Stage 4 → Finished (ships in the next ECMAScript edition)
As of 2026, Signals is at Stage 1. The collaborators on the Signal proposal want to be especially conservative in how they push this proposal forward, to avoid landing something that gets shipped but nobody ends up using. Their plan includes developing multiple production-grade polyfill implementations and integrating the proposed Signal API into a large number of JS frameworks before advancing further.
The Promises/A+ parallel: This effort is described as similar to the Promises/A+ effort which preceded the Promises standardised by TC39 in ES2015 — focusing on aligning the ecosystem first, then standardising based on that experience.
The Core Mental Model: Push-Then-Pull
The signal algorithm is not a push model. Making a change to a state signal does not eagerly push out updates to all derived computations. It is also not a pure pull model — reading a computed signal’s value doesn’t always trigger a re-computation. Rather, when state changes, it pushes only the dirty flag through the dependency graph. Any potential re-computation is delayed until a specific signal’s value is explicitly pulled. This is called a “push-then-pull” model: dirty flags are eagerly updated (pushed) while computations are lazily evaluated (pulled).
This design has a critical advantage: Signal.Computed is automatically memoised. If the source values haven’t changed, there is no need to re-compute.
The dependency graph looks like this:
State Signal (source)
│
│ push dirty flag when changed
▼
Computed Signal (derived)
│
│ pull value when read (lazy, memoised)
▼
Computed Signal (derived from derived)
│
▼
Effect / Watcher (side effect — render, log, sync)
The Proposed API
The Signal API is designed with a two-tier structure. APIs intended for application developers are exposed directly from the Signal namespace. APIs that should rarely appear in application code — more likely at the infrastructure layer — are exposed through the Signal.subtle namespace, similar to how Crypto.subtle marks the line between everyday cryptography use and low-level cryptographic primitives.
Signal.State — Writable State
Signal.State is the fundamental building block. It holds a value and notifies dependents when that value changes.
import { Signal } from 'signal-polyfill'
// Create a state signal with an initial value
const count = new Signal.State(0)
// Read the current value
count.get() // 0
// Write a new value
count.set(1)
count.get() // 1
Signal.Computed — Derived State
Signal.Computed creates a signal whose value is derived from other signals. It recomputes lazily and is automatically memoised.
const count = new Signal.State(0)
// Automatically tracks the dependency on `count`
const isEven = new Signal.Computed(() => (count.get() & 1) === 0)
const parity = new Signal.Computed(() => isEven.get() ? 'even' : 'odd')
count.get() // 0
parity.get() // 'even'
count.set(1)
parity.get() // 'odd' — recomputed because count changed
parity.get() // 'odd' — memoised, no recomputation
A More Complete Example
import { Signal } from 'signal-polyfill'
// Application state
const cartItems = new Signal.State([])
const taxRate = new Signal.State(0.08)
// Derived computations — automatically track dependencies
const subtotal = new Signal.Computed(() =>
cartItems.get().reduce((sum, item) => sum + item.price * item.qty, 0)
)
const tax = new Signal.Computed(() =>
subtotal.get() * taxRate.get()
)
const total = new Signal.Computed(() =>
subtotal.get() + tax.get()
)
// Reading values
total.get() // 0
// Updating state
cartItems.set([
{ name: 'Widget', price: 10, qty: 2 },
{ name: 'Gadget', price: 25, qty: 1 },
])
subtotal.get() // 45
tax.get() // 3.6
total.get() // 48.6
// Only total and tax recompute if taxRate changes — subtotal doesn't
taxRate.set(0.1)
tax.get() // 4.5
total.get() // 49.5
subtotal.get() // 45 — memoised, unchanged
The Subtle Namespace: Framework-Level Primitives
The Signal.subtle namespace contains the lower-level primitives that framework authors need but application developers rarely touch directly. The most important is Signal.subtle.Watcher.
Signal.subtle.Watcher — Observing Changes
A Watcher is notified synchronously when any watched signal becomes dirty (stale). This is the primitive used to implement effects — side-effectful reactions to state changes, like updating the DOM.
import { Signal } from 'signal-polyfill'
const count = new Signal.State(0)
const doubled = new Signal.Computed(() => count.get() * 2)
// Create a watcher — callback fires when a watched signal goes dirty
const watcher = new Signal.subtle.Watcher(() => {
// A signal we're watching just became dirty
// Schedule re-evaluation (don't read here — this is synchronous)
queueMicrotask(() => {
// Now we can safely re-read
for (const signal of watcher.getPending()) {
signal.get() // triggers re-evaluation
}
watcher.watch() // re-arm the watcher
})
})
watcher.watch(doubled)
doubled.get() // initial read — establishes tracking
count.set(5) // watcher fires → schedules microtask → doubled re-evaluates
Building an effect() Function
The Signal API does not include any built-in effect function. This is because effect scheduling is subtle and often ties into framework rendering cycles and other high-level, framework-specific strategies which JavaScript does not have access to. However, the proposal provides the primitives that framework authors can use to build their own.
Here is the pattern from the official polyfill for building a simple effect:
// effect.js — this would live in a framework/library, not your app
import { Signal } from 'signal-polyfill'
let needsEnqueue = true
const watcher = new Signal.subtle.Watcher(() => {
if (needsEnqueue) {
needsEnqueue = false
queueMicrotask(processPending)
}
})
function processPending() {
needsEnqueue = true
for (const signal of watcher.getPending()) {
signal.get()
}
watcher.watch()
}
export function effect(callback) {
let cleanup
const computed = new Signal.Computed(() => {
if (typeof cleanup === 'function') cleanup()
cleanup = callback()
})
watcher.watch(computed)
computed.get() // initial run
// Return a function to stop the effect
return () => {
watcher.unwatch(computed)
if (typeof cleanup === 'function') cleanup()
}
}
// Application code — using the framework-provided effect
import { effect } from './effect.js'
import { Signal } from 'signal-polyfill'
const username = new Signal.State('Guest')
const greeting = new Signal.Computed(() => `Hello, ${username.get()}!`)
// Runs immediately, then re-runs whenever greeting changes
const stop = effect(() => {
document.querySelector('#greeting').textContent = greeting.get()
})
username.set('Taylor') // → DOM updates automatically
stop() // clean up the effect when no longer needed
Signal.subtle.untrack — Escaping the Tracking Context
Sometimes you need to read a signal’s value without creating a dependency on it. Signal.subtle.untrack is the escape hatch.
import { Signal } from 'signal-polyfill'
const a = new Signal.State(1)
const b = new Signal.State(100)
// This computed only depends on `a` — reading `b` is untracked
const c = new Signal.Computed(() => {
const aValue = a.get()
const bValue = Signal.subtle.untrack(() => b.get())
return aValue + bValue
})
c.get() // 101
b.set(200)
// c does NOT recompute — b is not a tracked dependency
c.get() // still 101
a.set(2)
// c DOES recompute — a is tracked
c.get() // 202 (uses current b value of 200)
The Class Decorator Pattern
A class accessor decorator can be combined with the Signal.State() API to provide improved developer experience. This pattern makes signals feel natural in class-based code without exposing .get() / .set() everywhere.
import { Signal } from 'signal-polyfill'
// A simple decorator to make class fields reactive
function reactive(target, context) {
const signal = new Signal.State(target.get.call(this))
return {
get() { return signal.get() },
set(value) { signal.set(value) },
}
}
class ShoppingCart {
@reactive accessor items = []
@reactive accessor taxRate = 0.08
get subtotal() {
return new Signal.Computed(() =>
this.items.reduce((sum, item) => sum + item.price * item.qty, 0)
).get()
}
addItem(item) {
this.items = [...this.items, item]
}
}
How Frameworks Already Do This
The TC39 Signals proposal is not introducing a new concept — it’s standardising one that every major framework already implements. Here’s a side-by-side look at how the same reactive counter is expressed across the ecosystem:
// TC39 Proposal (native, using polyfill)
const count = new Signal.State(0)
const doubled = new Signal.Computed(() => count.get() * 2)
count.set(count.get() + 1)
// Solid.js
import { createSignal, createMemo } from 'solid-js'
const [count, setCount] = createSignal(0)
const doubled = createMemo(() => count() * 2)
setCount(c => c + 1)
// Vue 3 (Composition API)
import { ref, computed } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
count.value++
// Angular (v17+)
import { signal, computed } from '@angular/core'
const count = signal(0)
const doubled = computed(() => count() * 2)
count.update(c => c + 1)
// Preact
import { signal, computed } from '@preact/signals'
const count = signal(0)
const doubled = computed(() => count.value * 2)
count.value++
The semantics are identical across all five. The only difference is syntax. The goal of the proposal is to get these frameworks to re-platform their reactivity cores on a common shared implementation, so that signals produced by one framework are compatible with signals consumed by another.
Why This Matters: Interoperability
The practical benefit of a standard is interoperability. Today, a Vue component and a Solid component cannot share reactive state. If you use a third-party UI component library that’s built on Preact signals, its reactive state is isolated from your Angular signals.
With a common standard, the situation changes:
// Without standard Signals — two separate reactive graphs, no sharing
const vuePriceRef = ref(99) // Vue's reactive system
const solidPriceSignal = createSignal(99) // Solid's reactive system
// These can't communicate without manual bridging
// With standard Signals — one shared reactive graph
const price = new Signal.State(99)
// Both Vue and Solid re-platform on Signal.State internally
// Any library can consume or produce signals interoperably
Because all libraries and frameworks that adopt the standard will produce compatible signals, different web components won’t have to use the same library to interoperably consume and produce signals. Signals have the potential to become the foundation for a wide range of state management systems and observability libraries, new or existing.
Why the Proposal Doesn’t Include effect()
One of the most common questions about the proposal is why it doesn’t include a built-in effect() function — the most obviously useful reactive primitive.
The API is designed not to be especially ergonomic for application developers directly. Instead, the needs of library and framework authors are prioritised. Most frameworks are expected to wrap even the basic Signal.State and Signal.Computed APIs with something expressing their ergonomic slant.
The deeper reason is that effects are inseparable from scheduling — and scheduling is inseparable from rendering. React schedules effects after paint. Vue batches them into microtasks. Solid runs them synchronously. There is no universally correct answer, and baking one into the language standard would force an opinion that breaks at least some frameworks.
The proposal instead provides the primitives that a framework can use to build its own effects — specifically Signal.subtle.Watcher, which notifies synchronously when a watched signal becomes dirty, giving frameworks full control over when and how to schedule the resulting re-evaluation.
Using the Polyfill Today
The official polyfill is available now. It tracks the current state of the proposal and is suitable for experimentation and framework prototyping — though not for production code, as the API may evolve.
npm install signal-polyfill
import { Signal } from 'signal-polyfill'
import { effect } from './effect.js' // your own effect implementation
const temperature = new Signal.State(22)
const feeling = new Signal.Computed(() => {
const t = temperature.get()
if (t < 10) return 'cold'
if (t < 20) return 'cool'
if (t < 28) return 'comfortable'
return 'hot'
})
effect(() => {
console.log(`Current feeling: ${feeling.get()}`)
})
// → "Current feeling: comfortable"
temperature.set(35)
// → "Current feeling: hot"
Don’t use the polyfill in production yet. The polyfill is available but it’s best not to rely on its stability, as the API evolves during its review process. Use it to experiment, understand the model, and contribute feedback — not to ship features.
The Bigger Picture: Signals and the Web Platform
Current work in W3C and by browser implementors is seeking to bring native templating to HTML via DOM Parts and Template Instantiation. Additionally, the W3C Web Components CG is exploring the possibility of extending Web Components to offer a fully declarative HTML API. To accomplish both of these goals, a reactive primitive will eventually be needed by HTML itself.
This is the deepest motivation for the proposal. It’s not just about making framework interoperability nicer — it’s about giving the browser a reactive primitive it can use natively, unlocking a class of declarative DOM APIs that aren’t possible without it.
A future where Signal.State is built into the browser — where the browser’s own template system understands signals — is the end state this proposal is working toward. That’s years away. But the path there starts with standardising the primitive.
Summary: What to Know Right Now
| Question | Answer |
|---|---|
| What is a Signal? | A reactive primitive: state that automatically tracks its dependents and notifies them on change |
| What’s the current status? | TC39 Stage 1 — under active development, not yet standard |
| Who is involved? | Authors of Angular, Vue, Solid, Preact, Svelte, MobX, Ember, and more |
| What’s the API? | Signal.State, Signal.Computed, Signal.subtle.Watcher, Signal.subtle.untrack |
Is there an effect()? | No — frameworks implement their own using Signal.subtle.Watcher |
| Can I use it today? | Yes, via the signal-polyfill package — for experimentation only |
| Why does it matter? | Framework interoperability, smaller bundles, native browser support eventually |
| When will it ship natively? | Unknown — Stage 1 is early. Likely several years before Stage 4 |
Final Thoughts
The JavaScript framework ecosystem spent a decade independently discovering that signals are the right abstraction for reactive state. The TC39 proposal is the industry finally acknowledging that convergence and working to make it official.
The proposal is early. The API will change. Native browser support is years away. But the direction is clear, the coalition behind it is broad, and the motivation — a reactive primitive that every framework, library, and web component can share — is genuinely compelling.
If you’re a framework author, now is the time to engage with the proposal and experiment with re-platforming your reactivity on the polyfill. If you’re an application developer, now is the time to understand the model — because whether it’s called signal(), ref(), or Signal.State(), this is the reactive primitive the web is converging on.
The signal graph has already won. The language is just catching up.
