JavaScript Signals: The Reactive Paradigm for the Browser
AI generated
JS
() =>
JavaScript · TC39 · Reactivity · Signals
JavaScript Signals
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.

16 min read Signal · computed · effect · lazy evaluation · glitch-free TC39 Stage 2 · Solid.js · Angular · Vue

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.

11. FAQ: JavaScript Signals

1What TC39 stage are signals at?
Stage 2. The proposal is in the specification phase. Polyfill via npm install signal-polyfill for early experiments. Native browser support is still years away.
2Signal vs. Observable?
Signals: pull-based, synchronous state, lazy. Observables: push-based, event streams, asynchronous. Both are complementary, signals for UI state, observables for HTTP and events.
3Why no built-in effect()?
Effect semantics vary between frameworks. The proposal provides Signal.subtle.Watcher so every framework can build its own effect() with its own scheduling semantics.
4What does glitch-free mean?
No consumer ever sees an inconsistent state. The system guarantees that computed signals read all dependencies in a consistent snapshot, no mix of stale and new values.
5Angular Signals = TC39 proposal?
Similar, but a standalone implementation. Angular has contributed to the proposal and plans a migration to native signals once browser support is available. The API is deliberately aligned.
6What does Signal.subtle.untrack() do?
Runs a function without dependency tracking. get() calls inside untrack() do not register reactive dependencies, useful for snapshots and one-off reads inside effects.
7TypeScript support?
Fully typed. Signal.State<T> and Signal.Computed<T> infer T from the initial value or the return value of the computed function. signal-polyfill includes all type definitions.
8Computed vs. a normal getter?
Normal getter: always runs again. Signal.Computed: cached, only recalculated when a dependency has changed. Significantly more efficient for expensive computations.
9Computed on exception?
The exception propagates to the get() caller. The computed stores the exception and rethrows it on every subsequent read, until a dependency changes and a recalculation becomes possible.
10Signals in Web Workers?
Technically possible since no DOM access is required. Values between workers are message-based and copied, not shared. SharedArrayBuffer plus Atomics gives true shared state, but it is not a reactive system.