Back

JavaScript Signals Explained: Why Every Framework Is Adopting Them (And What It Means for React)

Something unusual is happening in the frontend world. Angular adopted Signals. Svelte replaced its reactivity model with Runes. Solid.js was built on them from day one. Vue's reactivity system has always been signal-like under the hood. Qwik, Preact, and even legacy frameworks like Ember are all converging on the same primitive.

Meanwhile, React—the framework that dominates the market—is conspicuously going in the opposite direction, betting everything on a compiler to fix the performance problems that signals solve by design.

The TC39 Signals proposal, now advancing through the JavaScript standards process, is attempting to bake this reactive primitive directly into the language itself. If it succeeds, signals won't just be a framework feature—they'll be part of JavaScript, like Promise or Array.

This is the most significant architectural shift in frontend development since the virtual DOM was introduced over a decade ago. Let's break down what's actually happening, why it matters, and what it means for the code you're writing today.


What Are Signals, Actually?

Strip away the framework-specific APIs, and a signal is a deceptively simple concept: a reactive container for a value that automatically notifies its dependents when it changes.

Think of it as an observable variable with automatic dependency tracking. When you read a signal inside a computation, the runtime remembers that dependency. When the signal's value changes, only the computations that actually depend on it are re-executed.

Here's the mental model in pseudocode:

// Create a reactive value const count = signal(0); // Create a derived computation — automatically tracks dependencies const doubled = computed(() => count.value * 2); // Create a side effect — re-runs when dependencies change effect(() => { console.log(`Count is ${count.value}, doubled is ${doubled.value}`); }); // Only the computations that depend on `count` re-run count.value = 5; // Console: "Count is 5, doubled is 10"

There's no dependency array. No manual subscription management. No diffing algorithm. The runtime knows exactly what depends on what because it observed the dependency graph at runtime.

The Three Primitives

Every signals implementation, regardless of framework, is built on three primitives:

1. Signal (State) — A reactive container holding a single value.

const name = signal("Alice"); console.log(name.value); // "Alice" name.value = "Bob"; // Notifies dependents

2. Computed (Derived State) — A value derived from one or more signals. It's lazy: it only recalculates when read, and only if a dependency has changed.

const firstName = signal("Alice"); const lastName = signal("Smith"); const fullName = computed(() => `${firstName.value} ${lastName.value}`); // fullName doesn't recalculate until you read it AND a dependency changed

3. Effect (Side Effects) — A function that runs whenever its tracked dependencies change. This is where you do DOM updates, network requests, or logging.

effect(() => { document.title = fullName.value; // Re-runs only when fullName changes });

This three-primitive model is the foundation. Now let's look at how deep the rabbit hole goes.


How Signals Work Under the Hood

The magic of signals isn't in the API—it's in the automatic dependency tracking. Let's build a simplified signals runtime from scratch to understand the internals.

The Dependency Graph

At its core, a signals runtime maintains a directed acyclic graph (DAG) of dependencies:

┌─────────┐     ┌─────────┐
│ signal A │────▶│computed C│────▶ effect E
└─────────┘     └─────────┘
┌─────────┐        ▲
│ signal B │────────┘
└─────────┘

When Signal A changes, the runtime walks the graph and only re-executes Computed C and Effect E. Signal B's other dependents (if any) are untouched.

A Minimal Implementation

Here's a working signals implementation in ~50 lines of JavaScript:

let currentObserver = null; function signal(initialValue) { let value = initialValue; const subscribers = new Set(); return { get value() { // Track: if someone is observing, register this signal if (currentObserver) { subscribers.add(currentObserver); } return value; }, set value(newValue) { if (newValue === value) return; // Skip if unchanged value = newValue; // Notify: re-run all subscribers for (const subscriber of subscribers) { subscriber(); } } }; } function computed(fn) { let cachedValue; let dirty = true; const computation = () => { dirty = true; }; return { get value() { if (dirty) { const prevObserver = currentObserver; currentObserver = computation; cachedValue = fn(); currentObserver = prevObserver; dirty = false; } // Also track this computed as a dependency for outer observers if (currentObserver) { // (simplified — production implementations use a Set here too) } return cachedValue; } }; } function effect(fn) { const execute = () => { const prevObserver = currentObserver; currentObserver = execute; fn(); currentObserver = prevObserver; }; execute(); // Run immediately to establish dependencies }

The key technique is the global observer stack (currentObserver). When a computed or effect function runs, it sets itself as the current observer. Any signal that gets read during that execution automatically adds the observer to its subscriber set. This is why you never need to declare dependencies manually.

Production Optimizations

Real signals implementations add several critical optimizations over this naive approach:

1. Push-Pull Evaluation — Instead of eagerly re-running all subscribers when a signal changes (push), modern implementations mark downstream computations as "dirty" (push) and only recalculate when their value is read (pull). This avoids unnecessary computation in large graphs.

2. Glitch-Free Execution — If Signal A and Signal B both change in the same microtask, a computed that depends on both should only run once, not twice. Production implementations use topological sorting or batching to ensure consistency.

const a = signal(1); const b = signal(2); const sum = computed(() => a.value + b.value); // Without glitch prevention: batch(() => { a.value = 10; // sum re-runs: 10 + 2 = 12 b.value = 20; // sum re-runs: 10 + 20 = 30 }); // With glitch prevention: sum only runs once with (10, 20)

3. Automatic Cleanup — When an effect re-runs, its old dependency subscriptions are automatically cleared and re-established. This prevents memory leaks from stale subscriptions.

4. Equality Checks — Signals skip notifications if the new value is identical to the old value (using Object.is by default), preventing unnecessary downstream updates.


The TC39 Signals Proposal

The most exciting development isn't happening inside any framework—it's happening at the language level. The TC39 Signals proposal aims to standardize the reactive primitive directly in JavaScript.

Why Standardize?

Every framework has its own signals implementation. Angular Signals can't interoperate with Solid's createSignal. A date picker library built for Preact Signals won't work in a Vue app without wrappers.

The TC39 proposal solves this with a standard Signal API that all frameworks can build on:

// TC39 Proposal API (Stage 1, subject to change) const counter = new Signal.State(0); const isEven = new Signal.Computed(() => (counter.get() & 1) === 0); // Frameworks wrap this with their own ergonomic APIs // but the underlying reactive graph is shared

What the Proposal Provides

The proposal focuses on the reactive graph algorithm rather than the rendering layer:

  • Signal.State — Writable reactive value
  • Signal.Computed — Derived reactive value (lazy, cached)
  • Signal.subtle.Watcher — Low-level API for framework integration (replaces effect)

Critically, the proposal does not include effect(). Effects are framework-specific—how you update the DOM, schedule renders, or batch changes is left to the framework. The standard only provides the reactive graph primitives.

The Interoperability Vision

Imagine a future where:

  • A charting library uses Signal.State internally
  • You use it in an Angular app with Angular Signals (built on Signal.State)
  • Your colleague uses the same library in a Solid app
  • Reactivity flows through seamlessly because the graph is shared

This is the interoperability dream—shared state reactivity across the entire JavaScript ecosystem.


The Framework Landscape: Who Uses Signals and How

Angular Signals (v17+)

Angular's adoption of signals was a seismic event. The framework famous for RxJS, Zones, and change detection rewrote its reactivity model around signals:

// Angular Signal API import { signal, computed, effect } from '@angular/core'; @Component({ template: ` <h1>{{ fullName() }}</h1> <button (click)="updateName()">Change Name</button> ` }) export class UserComponent { firstName = signal('Alice'); lastName = signal('Smith'); // Derived state — only recalculates when dependencies change fullName = computed(() => `${this.firstName()} ${this.lastName()}`); // Explicit effect for side effects logger = effect(() => { console.log(`Name changed to: ${this.fullName()}`); }); updateName() { this.firstName.set('Bob'); } }

The key architectural change: Angular can now skip Zone.js entirely for change detection. Instead of dirty-checking the entire component tree on every event, Angular only updates the specific DOM nodes bound to changed signals. This resulted in 30-50% faster rendering in real-world benchmarks.

Solid.js — Signals from Day One

Solid.js was arguably the framework that proved signals could power a production-grade UI framework:

import { createSignal, createMemo, createEffect } from "solid-js"; function Counter() { const [count, setCount] = createSignal(0); const doubled = createMemo(() => count() * 2); createEffect(() => { console.log(`Count: ${count()}, Doubled: ${doubled()}`); }); return ( <button onClick={() => setCount(c => c + 1)}> {count()} × 2 = {doubled()} </button> ); }

The critical difference from React: Solid doesn't re-run components. The Counter function executes exactly once. The JSX is compiled to fine-grained DOM update instructions that subscribe to specific signals. When count changes, only the text nodes that display count() and doubled() are updated—the component function itself never re-runs.

This means no virtual DOM diffing, no re-renders, and no need for memoization. Ever.

Svelte Runes (v5)

Svelte 5 replaced its previous compile-time reactivity (the $: label syntax) with Runes, which are essentially compile-time signals:

<script> let count = $state(0); let doubled = $derived(count * 2); $effect(() => { console.log(`Count: ${count}, Doubled: ${doubled}`); }); </script> <button onclick={() => count++}> {count} × 2 = {doubled} </button>

The elegance of Runes is that they look like normal variables. The compiler transforms $state, $derived, and $effect into the underlying signal primitives, but the developer experience feels like writing plain JavaScript.

Vue's Reactivity (Composition API)

Vue has used a signal-like reactivity system since Vue 3's Composition API (and internally since Vue 2 with Object.defineProperty):

<script setup> import { ref, computed, watchEffect } from 'vue'; const count = ref(0); const doubled = computed(() => count.value * 2); watchEffect(() => { console.log(`Count: ${count.value}, Doubled: ${doubled.value}`); }); </script> <template> <button @click="count++"> {{ count }} × 2 = {{ doubled }} </button> </template>

Vue's ref is Signal. computed is Computed. watchEffect is Effect. The naming is different, but the underlying reactive graph is architecturally identical. Vue was doing signals before signals were cool.

Preact Signals

Preact took a unique approach by adding signals as a companion library that integrates with the virtual DOM:

import { signal, computed, effect } from "@preact/signals"; const count = signal(0); const doubled = computed(() => count.value * 2); function Counter() { return ( <button onClick={() => count.value++}> {count} × 2 = {doubled} </button> ); }

What makes Preact Signals special: you can pass a signal directly into JSX ({count} instead of {count.value}), and Preact will subscribe to it at the DOM level, bypassing the virtual DOM diff entirely for that node. This gives Preact near-Solid-level performance while keeping the familiar React-like component model.


React's Divergent Path: Hooks + Compiler vs. Signals

Here's where it gets controversial. Every major framework is converging on signals, but React—the most widely used framework—has explicitly chosen not to adopt them. Understanding why reveals fundamental differences in philosophy.

React's Argument Against Signals

The React team has articulated several reasons for avoiding signals:

1. Top-Down Data Flow — React is designed around the idea that components are functions of their props and state. You call setState, the component re-runs, and React diffs the output. Signals break this model because they update DOM nodes directly, bypassing the component function.

2. Debuggability — In React's model, you can place a breakpoint in any component and see the full render on every state change. With signals, updates happen granularly—there's no "render cycle" to step through. The React team argues this makes debugging harder.

3. The Compiler Bet — React's position is that the compiler can deliver signal-like performance without changing the programming model. If the compiler can automatically determine what to skip, developers get the performance benefits without learning a new reactivity paradigm.

The Counter-Arguments

Critics of React's approach point to several issues:

1. Fundamental Overhead — Even with perfect memoization, React still re-runs component functions and diffs virtual DOM trees. Signals skip both steps entirely. There's a performance ceiling that compilation can't break through.

React (with compiler):
  State change → Re-run component function → Diff vDOM → Patch DOM

Signals:
  State change → Patch DOM directly

// The "re-run + diff" steps have non-zero cost, even when optimized

2. Runtime Cost — The React Compiler's memoization adds runtime overhead (cache lookups, equality checks on every memoized value). Signals only do work when values actually change, with zero overhead on unchanged values.

3. The Rules of React — The compiler requires code to follow "the Rules of React" (pure components, no mutations during render, correct hook ordering). Violations cause silent correctness bugs, not just performance issues. Signals have no such constraint—they're just values.

4. Ecosystem Fragmentation — If TC39 standardizes signals, every framework except React will share a common reactive primitive. React components will be the odd ones out, unable to participate in the broader signals ecosystem.

Performance Comparison

Let's look at approximate numbers based on publicly available results from the js-framework-benchmark (a standardized stress test of framework performance). These figures represent general performance trends for a 10,000-row table:

OperationReact 19 + CompilerSolid.js (Signals)Angular (Signals)Svelte 5 (Runes)
Create 10k rows~420ms~190ms~230ms~200ms
Update every 10th row~80ms~18ms~25ms~20ms
Swap two rows~45ms~12ms~15ms~14ms
Select row~8ms~2ms~3ms~2ms
Remove row~38ms~6ms~9ms~7ms
Memory (after create)~9 MB~4 MB~4.5 MB~3.5 MB

Note: These are approximate values based on publicly available benchmark trends. Actual numbers vary by hardware, browser, and framework version. Check js-framework-benchmark for latest results.

The pattern is consistent: signals-based frameworks outperform React's compiler-optimized approach by roughly 2-4x across most operations. The memory difference is even more dramatic because signals don't maintain a virtual DOM tree.


Practical Migration: Adding Signals to Your Stack

If you're interested in adopting signals today, here are practical paths depending on your current framework.

If You're on Angular

You're already there. Angular 17+ signals are production-ready. Start migrating from RxJS-heavy patterns:

// Before: RxJS Observables @Component({...}) export class UserComponent implements OnInit, OnDestroy { user$!: Observable<User>; private destroy$ = new Subject<void>(); ngOnInit() { this.user$ = this.userService.getUser().pipe( takeUntil(this.destroy$) ); } ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); } } // After: Angular Signals + resource API @Component({...}) export class UserComponent { userId = input.required<string>(); user = resource({ request: () => this.userId(), loader: ({ request: id }) => this.userService.getUser(id) }); }

No subscriptions to manage. No takeUntil patterns. No OnDestroy cleanup.

If You're on React (Using Preact Signals)

You can actually use signals in React today via @preact/signals-react:

import { signal, computed } from "@preact/signals-react"; const count = signal(0); const doubled = computed(() => count.value * 2); function Counter() { // The component still re-renders, but signal updates // are batched and optimized return ( <div> <p>{count.value} × 2 = {doubled.value}</p> <button onClick={() => count.value++}>Increment</button> </div> ); }

Caveat: this is a third-party integration. It works by hooking into React's render cycle, so you don't get the full performance benefits of native signals (no vDOM bypass). But it gives you the ergonomic benefits of automatic dependency tracking without manual useMemo/useCallback.

If You're Starting from Scratch

If you're choosing a framework for a new project in 2026 and performance is critical:

  1. Solid.js — Maximum performance, smallest bundle, most "pure" signals experience
  2. Svelte 5 — Best developer experience (Runes look like plain JS), excellent performance
  3. Angular — Best choice for large enterprise teams (TypeScript-native, comprehensive tooling)
  4. Vue — Great balance of performance and ecosystem maturity

Building a Real-World Example: Reactive Form Validation

Let's build something practical to see signals in action. Here's a reactive form with real-time validation, implemented in multiple frameworks:

Vanilla Signals (TC39 Proposal Style)

// Using the proposed TC39 API const email = new Signal.State(""); const password = new Signal.State(""); const emailError = new Signal.Computed(() => { const value = email.get(); if (!value) return "Email is required"; if (!value.includes("@")) return "Invalid email format"; return null; }); const passwordStrength = new Signal.Computed(() => { const value = password.get(); if (value.length === 0) return "empty"; if (value.length < 8) return "weak"; if (/(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%])/.test(value)) return "strong"; return "medium"; }); const isFormValid = new Signal.Computed(() => { return emailError.get() === null && passwordStrength.get() !== "weak" && passwordStrength.get() !== "empty"; }); // This derived graph automatically updates: // email changes → emailError recalculates → isFormValid recalculates // password changes → passwordStrength recalculates → isFormValid recalculates // No manual dependency wiring needed

Solid.js Implementation

import { createSignal, createMemo, Show } from "solid-js"; function SignupForm() { const [email, setEmail] = createSignal(""); const [password, setPassword] = createSignal(""); const emailError = createMemo(() => { const value = email(); if (!value) return "Email is required"; if (!value.includes("@")) return "Invalid email format"; return null; }); const passwordStrength = createMemo(() => { const value = password(); if (value.length === 0) return "empty"; if (value.length < 8) return "weak"; if (/(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%])/.test(value)) return "strong"; return "medium"; }); const isValid = createMemo(() => emailError() === null && !["weak", "empty"].includes(passwordStrength()) ); return ( <form> <input type="email" value={email()} onInput={(e) => setEmail(e.target.value)} classList={{ error: !!emailError() }} /> <Show when={emailError()}> <span class="error">{emailError()}</span> </Show> <input type="password" value={password()} onInput={(e) => setPassword(e.target.value)} /> <div class={`strength-${passwordStrength()}`}> Strength: {passwordStrength()} </div> <button disabled={!isValid()}>Sign Up</button> </form> ); }

When the user types in the email field:

  1. Only email signal changes
  2. Only emailError recalculates (not passwordStrength)
  3. Only isValid recalculates
  4. Only the DOM nodes bound to emailError() and isValid() update

The password input, strength indicator, and all other DOM nodes are completely untouched. In React, the entire component function would re-run, every JSX expression would be re-evaluated, and React would diff the entire virtual DOM subtree.


The Big Picture: Where Frontend Is Heading

The convergence on signals is not a coincidence. It's driven by several converging forces:

1. The Performance Ceiling of Virtual DOM

Virtual DOM diffing was a brilliant idea in 2013 when JavaScript engines were slow and DOM manipulation was expensive. In 2026, JavaScript engines are extraordinarily fast, and the overhead of "create virtual tree → diff → patch" has become the bottleneck, not the DOM itself.

Signals eliminate the middleman. State change → DOM update. No diffing step. This matters increasingly as applications grow in complexity.

2. The Rise of Islands Architecture

Frameworks like Astro, Qwik, and even Next.js (with RSC) are moving toward "islands" of interactivity in a sea of static HTML. Signals are a natural fit for islands because each island can have its own local reactive graph without affecting the rest of the page.

3. Server-Side Reactivity

Signals aren't just for the browser. Server-side signals enable reactive data pipelines, real-time APIs, and SSR that can serialize the reactive graph and resume it on the client (Qwik's "resumability" model).

4. The TC39 Endgame

If Signal.State and Signal.Computed become part of the JavaScript standard:

  • Every framework built on top of them automatically interoperates
  • Browser engines can optimize the reactive graph at the native level
  • Third-party libraries (date pickers, form libraries, state managers) can use a universal reactive primitive

This is the end of the "framework lock-in" era for state management. A signals-based library works in Angular, Solid, Svelte, Vue, and any future framework.


Conclusion

The frontend world is undergoing a reactivity revolution. Signals—a simple primitive of reactive state, derived computations, and effects—have proven to be a fundamentally more efficient model for UI state management than the virtual DOM diffing approach that React popularized.

Every major framework except React has adopted signals. The TC39 proposal is working to standardize them in JavaScript itself. Angular is seeing 30-50% rendering improvements after migration. Solid.js delivers 2-4x the performance of React in standardized benchmarks. Svelte's Runes made signals feel like plain JavaScript.

React's bet on the compiler is bold and may narrow the gap, but it cannot eliminate the fundamental architectural overhead of re-running components and diffing virtual trees. Whether React eventually adopts signals, or succeeds in proving the compiler approach superior, the competition is making the entire ecosystem better.

Regardless of which framework you're using today, understanding signals isn't optional anymore—it's table stakes. The reactive graph is the future of frontend state management, and it's already here.

JavaScriptSignalsReactAngularFrontendTC39Performance

Explore Related Tools

Try these free developer tools from Pockit