The reactive paradigm as a browser standard
Reactive programming is solved differently in every modern frontend framework, with incompatible APIs and abstractions hidden behind framework boundaries. The TC39 Signals proposal defines a universal foundation for the first time: Signal, computed, and effect as native JavaScript primitives that frameworks can build on.
Table of Contents
- 1. The problem of reactive programming in JavaScript
- 2. What a signal is, and what it is not
- 3. computed(): derived state without manual tracking
- 4. effect(): controlling side effects reactively
- 5. Lazy evaluation and glitch-free semantics
- 6. The TC39 proposal API in detail
- 7. Signals in Solid.js, Angular, and Vue
- 8. Interoperability: signals across framework boundaries
- 9. Signals vs. other reactivity models
- 10. Summary
- 11. FAQ
1. The problem of reactive programming in JavaScript
Reactive programming describes a model in which state is propagated automatically: when a value changes, all dependent computations and side effects update themselves without manual intervention. This principle is indispensable in UI development: when the user types their name, the greeting text should update immediately, without a cascade of event handlers having to be managed by hand. The problem is that every framework solves this fundamental problem differently. React uses useState and the virtual DOM diff algorithm, Vue uses a proxy-based reactive system, Angular has (new) Signals alongside RxJS Observables, and Svelte compiles reactivity away at build time.
JavaScript Signals address this fragmentation at the language level. The TC39 Signals proposal defines a minimal but complete API that forms the foundation for framework reactivity, without prescribing a specific framework model. The idea is that frameworks like Solid.js, Preact Signals, Angular, and Vue should be able to build their reactivity layer on native Signals. Libraries and Web Components could then use the same signal primitives without being tied to a framework, an interoperability that does not exist today.
2. What a signal is, and what it is not
A Signal is a reactive data container. It stores a value, automatically tracks every place in the code that reads that value, and notifies those dependents when the value changes. That is the difference from an ordinary variable: a variable is passive, nothing happens automatically when it changes. A Signal is active, it performs dependency tracking and propagates changes through the reactive system.
What a signal is not: it is not an observable like in RxJS. Observables are push-based and describe streams of events over time. Signals are pull-based with lazy evaluation, the current value is always read synchronously, and the reactive system updates dependents on demand. It is also not a store like in Redux or Zustand: a store is an external state source with explicit actions and reducer functions. A signal is a simple value container that achieves reactivity through implicit tracking instead of explicit publish-subscribe. This simplicity is at the core of the signal design.
// TC39 Signals Proposal (Signal.State and Signal.Computed)
// (Polyfill: npm install signal-polyfill)
import { Signal } from 'signal-polyfill';
// Signal.State: writable reactive value
const count = new Signal.State(0);
const name = new Signal.State('World');
// Reading the current value
console.log(count.get()); // 0
// Writing a new value, automatically notifies dependents
count.set(1);
count.set(count.get() + 1); // increment
// Signal.Computed: derived reactive value (lazy + cached)
const greeting = new Signal.Computed(() => `Hello, ${name.get()}!`);
const doubled = new Signal.Computed(() => count.get() * 2);
// computed is lazy, only evaluated when actually read
console.log(greeting.get()); // 'Hello, World!'
name.set('Mironsoft');
// greeting is now stale, but NOT yet re-evaluated
console.log(greeting.get()); // 'Hello, Mironsoft!' (evaluated on demand)
console.log(doubled.get()); // 4 (count is 2)
3. computed(): derived state without manual tracking
A computed signal derives its value from other signals. The crucial point is that dependency tracking is entirely automatic. The developer does not need to declare which signals a computed depends on, the reactive system determines the dependencies dynamically during the first run of the computation function. Every Signal.get() call inside the computed function automatically registers a dependency. When one of those source signals is later updated, the system marks the computed signal as stale.
What makes computed signals elegant is the automatic cascading: a computed signal can read other computed signals, which in turn read other computed signals. The system builds a dependency graph and propagates changes correctly through every level. At the same time, the result of a computed signal is cached: it is not recalculated immediately with every change to a source, only when it is actually read. This lazy evaluation is the key to the performance efficiency of the signal model.
4. effect(): controlling side effects reactively
An effect is the mechanism through which reactive Signals affect the real world: DOM updates, network requests, logging, LocalStorage writes. The proposal does not define a built-in effect primitive (deliberately), but it does provide the tool to build one: Signal.subtle.Watcher. A watcher registers itself with signals and is notified when one of the observed signals is marked stale. Frameworks use this low-level API to build their own effect implementations.
The reason the proposal does not ship a ready-made effect(): effects have different semantics in different frameworks. When does an effect run, synchronously, asynchronously, in a microtask? How is cleanup handled? How does an effect behave if it throws an exception during execution? The proposal deliberately leaves these questions open and provides only the tool, Signal.subtle.Watcher. Frameworks like Solid.js, Preact Signals, and Angular can implement different effect semantics, all built on the same signal foundation.
// Building an effect() on top of Signal.subtle.Watcher
import { Signal } from 'signal-polyfill';
// Simple effect implementation using the Watcher primitive
function effect(fn) {
let cleanup;
const watcher = new Signal.subtle.Watcher(() => {
// Called synchronously when a dependency becomes stale
// Schedule re-execution asynchronously to batch updates
queueMicrotask(run);
});
function run() {
// Stop watching previous dependencies
watcher.unwatch(...watcher.getPending());
// Clear previous cleanup
if (cleanup) cleanup();
// Re-execute fn, tracking all signal reads
watcher.watch(computed);
cleanup = Signal.subtle.untrack(() => fn());
}
const computed = new Signal.Computed(fn);
watcher.watch(computed);
run(); // Execute immediately
// Return disposal function
return () => {
watcher.unwatch(computed);
if (cleanup) cleanup();
};
}
// Usage
const temperature = new Signal.State(20);
const unit = new Signal.State('C');
const display = new Signal.Computed(() =>
unit.get() === 'C'
? `${temperature.get()}°C`
: `${(temperature.get() * 9/5 + 32).toFixed(1)}°F`
);
// Effect runs immediately and on every dependency change
const dispose = effect(() => {
document.title = `Temperatur: ${display.get()}`;
});
temperature.set(25); // title updates to "Temperatur: 25°C"
unit.set('F'); // title updates to "Temperatur: 77.0°F"
dispose(); // stop the effect
5. Lazy evaluation and glitch-free semantics
Two concepts are central to understanding JavaScript Signals: lazy evaluation and glitch-free semantics. Lazy evaluation means that computed signals do not calculate their value immediately when a dependency changes. Instead, they are marked stale. The recalculation only happens when the value is actually read. That is the fundamental difference from a push-based system like RxJS, which immediately propagates new values through every subscription.
Glitch-free semantics describes the property that consumers of a reactive system never see an inconsistent state. A "glitch" in reactive systems arises when a computed signal is read before all of its dependencies have been updated. Example: if a changes, and a computed b depends on a, and another computed c depends on both a and b, a naive system could compute c with a stale b and the new a, an inconsistent result. The Signal proposal guarantees glitch-free semantics through the topological sorting mechanism of the dependency graph.
6. The TC39 proposal API in detail
The proposal defines the signal API under the Signal namespace. Signal.State is the writable base signal type with the methods get() and set(value). Signal.Computed is the derived, read-only signal type with only get(). Its constructor takes a computation function and optional configuration parameters, including a custom equality check that decides whether a change to the computed value should notify dependents.
Under Signal.subtle sits the low-level API for framework authors: Signal.subtle.Watcher is the mechanism for reacting to stale notifications. Signal.subtle.untrack(fn) runs a function without tracking dependencies, useful inside effects for accessing signal values without registering them as a dependency. Signal.subtle.currentlyTracking() returns the currently running computed, which framework authors can use for debugging tools. This separation between the end-user API and the framework API is a deliberate design decision of the Signals proposal.
// Advanced Signal patterns (custom equality, untrack, batch)
import { Signal } from 'signal-polyfill';
// Custom equality: only notify dependents when reference changes
const userProfile = new Signal.State(
{ name: 'Alice', role: 'admin' },
{
equals: (prev, next) =>
prev.name === next.name && prev.role === next.role,
}
);
// Reading without tracking, useful inside effects for one-time reads
const currentCount = new Signal.State(0);
const isExpensive = new Signal.Computed(() => {
// Access currentCount without creating a dependency
const snapshot = Signal.subtle.untrack(() => currentCount.get());
return snapshot > 1000;
});
// Composition: signals built from other signals
const firstName = new Signal.State('Ada');
const lastName = new Signal.State('Lovelace');
const fullName = new Signal.Computed(() => `${firstName.get()} ${lastName.get()}`);
const slug = new Signal.Computed(() =>
fullName.get().toLowerCase().replace(/\s+/g, '-')
);
// slug depends on fullName which depends on firstName and lastName
// When firstName changes, slug is re-evaluated automatically
firstName.set('Grace');
console.log(slug.get()); // 'grace-lovelace'
7. Signals in Solid.js, Angular, and Vue
The TC39 Signals proposal is strongly inspired by existing framework implementations. Solid.js has used a signal-based reactivity model since its inception: createSignal() returns a state signal, createMemo() corresponds to computed, and createEffect() corresponds to effect. Solid.js is considered a direct precursor to the proposal because it proved that signal-based reactivity can be more performant than VDOM-based approaches. The Solid.js implementation is so efficient that it outperforms React in many benchmarks.
Angular introduced its own signals model starting with version 16, closely aligned with the TC39 proposal. Angular signals are accessible via signal(), computed(), and effect(), names deliberately chosen to align with the proposal. Vue 3's reactivity system with ref(), computed(), and watchEffect() implements the same underlying principle under different names. The goal of the TC39 proposal is for these frameworks to be able to migrate their internal reactivity primitives onto native JavaScript Signals once the proposal reaches Stage 4. That would enable interoperability of reactive data across framework boundaries.
8. Interoperability: signals across framework boundaries
The most important long-term impact of the JavaScript Signals proposal is interoperability. Today, a signal value from a Solid.js context is invisible to a Vue component and vice versa. Web Components have no standardized reactive system. If native Signals are available in the browser, Web Components could read signal values and export signals themselves. An Angular component could consume a signal from a Solid.js library without needing adapters or wrappers.
This interoperability is especially valuable for building design system libraries and Web Components that are meant to be framework-agnostic. Instead of maintaining a separate wrapper library for every framework, a Web Component could accept and expose a native Signal as a property. The framework-specific code would then live exclusively in each framework's component integration layer. That would significantly reduce the fragmentation of the frontend ecosystem, one of the most ambitious goals of the TC39 Signals proposal.
9. Signals vs. other reactivity models
Understanding the differences between JavaScript Signals and other reactivity models is essential for applying them correctly. Signals are pull-based with lazy evaluation. RxJS Observables are push-based and are especially well suited for asynchronous event streams with complex transformations. React state with useReducer models state changes as discrete actions in a state machine. Redux amplifies this pattern with a single-source-of-truth store.
| Model | Evaluation strategy | Dependency tracking | Strength |
|---|---|---|---|
| JS Signals (TC39) | Pull, lazy, cached | Automatic, implicit | Glitch-free, interoperable |
| RxJS Observables | Push, immediate | Explicit (subscribe) | Asynchronous streams, operators |
| React useState | Push, re-render based | Implicit (VDOM diff) | Simple model, large ecosystem |
| Vue ref/computed | Pull, lazy, cached | Automatic (proxy) | Simple API, proxy-based |
| Redux / Zustand | Push, selector-based | Explicit (selectors) | Predictable, time-travel capable |
JavaScript Signals are not meant as a replacement for RxJS. They solve different problems: signals model synchronous, derived state, "what is the current value of this computation?" Observables model event streams over time, "what happens next in this stream?" In an Angular application, signals and RxJS complement each other: signals for UI state, observables for HTTP requests and complex event transformations.
Mironsoft
Modern frontend, reactive architectures, and performant JavaScript applications
Reactive architecture for your application?
We advise on and implement reactive state models with Signals, Solid.js, Angular, or Vue, tailored to the requirements of your application and your team.
Architecture consulting
Choosing a reactivity model: signals, observables, or a store, with a concrete recommendation for your project
Migration
Angular signals migration, Solid.js adoption, or Vue composables modernization
Performance optimization
Eliminate rendering bottlenecks with signals and measurably reduce unnecessary re-renders
10. Summary
The JavaScript Signals proposal brings reactive programming into the language as a standardized primitive. Signal.State for writable state, Signal.Computed for derived computations with automatic dependency tracking, and Signal.subtle.Watcher for effects, together these three primitives form a complete foundation for reactive UI architecture. Lazy evaluation and glitch-free semantics are the technical guarantees that make the model correct and efficient.
The long-term significance of native JavaScript Signals lies in interoperability across framework boundaries. If Solid.js, Angular, Vue, and Web Components all build on the same signal foundation, reactive data can flow seamlessly across framework boundaries. The TC39 proposal is currently at Stage 2; the final implementation in browsers is still several years away. With the signal-polyfill polyfill, however, signals and the Signal.subtle.Watcher mechanism can already be explored in production projects today.
JavaScript Signals: the essentials at a glance
Primitives
Signal.State (writable), Signal.Computed (derived, lazy), Signal.subtle.Watcher (for effects). Three primitives make up a complete reactive system.
Tracking
Automatic dependency tracking without manual declaration. Every get() call inside a computed implicitly registers a dependency.
Lazy + glitch-free
computed signals are only recalculated when read. Glitch-free guarantees topologically consistent evaluation with no inconsistent intermediate states.
Status
TC39 Stage 2. Polyfill: signal-polyfill on npm. Solid.js, Angular Signals, and Vue ref() are framework precursors of the same paradigm.