The way frontend frameworks manage state has undergone a fundamental shift. After years of virtual DOM diffing, component-level re-renders, and increasingly complex state management libraries, the industry has converged on a deceptively simple primitive: signals. SolidJS pioneered the modern approach, Angular adopted it officially, Vue refined its reactivity engine around a similar concept, and even React is experimenting with compiler-driven fine-grained updates. Understanding signals is no longer optional for frontend developers building performant applications in 2026.
This article dissects how signals work under the hood, compares their implementation across four major frameworks, and provides practical guidance for choosing the right reactivity model for your next project. If you have been following modern design patterns, you will recognize signals as an evolution of the Observer pattern tailored specifically for UI rendering.
What Are Signals?
A signal is a reactive primitive that holds a value and automatically notifies its dependents whenever that value changes. Unlike traditional state management where you explicitly subscribe and unsubscribe, signals build a dependency graph at runtime. When a signal updates, only the computations and DOM nodes that actually depend on it are re-executed.
The core concept rests on three pillars:
- Signal (State) — A container for a value that can be read and written. Reading a signal inside a reactive context registers a dependency.
- Computed (Derived) — A read-only value derived from one or more signals. It re-computes lazily when its dependencies change.
- Effect (Side Effect) — A function that runs whenever its tracked dependencies change. Used for DOM updates, logging, network requests, and other side effects.
This model differs fundamentally from virtual DOM diffing. Instead of re-rendering an entire component tree and comparing the old and new output, signals allow the framework to update only the specific DOM text node or attribute that actually changed. The result is dramatically fewer DOM operations and less garbage collection pressure.
SolidJS: The Pioneer of Modern Signals
SolidJS, created by Ryan Carniato, brought signals into the mainstream frontend conversation. Its reactivity system compiles JSX into real DOM operations at build time, with signals providing the dynamic bindings. There is no virtual DOM, no component re-rendering, and no reconciliation pass. When you understand how server component architectures split work between server and client, SolidJS represents the opposite philosophy: making client-side rendering so efficient that the split becomes less critical.
Creating and Using Signals in Solid
import { createSignal, createEffect, createMemo } from "solid-js";
function Counter() {
// Create a signal with initial value 0
const [count, setCount] = createSignal(0);
// Derived computation - only re-runs when count changes
const doubled = createMemo(() => count() * 2);
// Side effect - runs whenever count changes
createEffect(() => {
console.log(`Count is now: ${count()}, doubled: ${doubled()}`);
});
return (
<div>
<p>Count: {count()}</p>
<p>Doubled: {doubled()}</p>
<button onClick={() => setCount(c => c + 1)}>
Increment
</button>
</div>
);
}
Notice that count is a function call, not a value. This is essential to how Solid tracks dependencies. When the JSX compiler sees {count()}, it wraps that expression in a fine-grained reactive computation that updates only that specific text node when count changes. The component function itself runs exactly once.
Stores for Complex State
For deeply nested state, Solid provides stores that make individual properties reactive:
import { createStore } from "solid-js/store";
const [state, setState] = createStore({
user: {
name: "Alice",
preferences: {
theme: "dark",
language: "en"
}
},
items: []
});
// Only components reading state.user.preferences.theme re-render
setState("user", "preferences", "theme", "light");
// Array operations with fine-grained tracking
setState("items", items => [...items, { id: 1, label: "New Item" }]);
Angular Signals: Official Adoption
Angular's adoption of signals starting in version 16 represented a seismic shift for the framework. After years of relying on Zone.js for change detection, signals provide a path to zoneless, fine-grained reactivity. This aligns with the broader industry trend toward more predictable and performant patterns, as explored in our guide to advanced TypeScript patterns.
Signal Primitives in Angular
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-shopping-cart',
template: `
<div>
<h2>Shopping Cart</h2>
<p>Items: {{ itemCount() }}</p>
<p>Total: {{ formattedTotal() }}</p>
<button (click)="addItem()">Add Item</button>
</div>
`
})
export class ShoppingCartComponent {
// Writable signal
items = signal<CartItem[]>([]);
taxRate = signal(0.08);
// Computed signals - automatically tracked
itemCount = computed(() => this.items().length);
subtotal = computed(() =>
this.items().reduce((sum, item) => sum + item.price, 0)
);
total = computed(() =>
this.subtotal() * (1 + this.taxRate())
);
formattedTotal = computed(() =>
`$${this.total().toFixed(2)}`
);
constructor() {
// Effects for side effects
effect(() => {
console.log(`Cart updated: ${this.itemCount()} items, ${this.formattedTotal()}`);
});
}
addItem() {
this.items.update(items => [
...items,
{ id: crypto.randomUUID(), name: 'Widget', price: 29.99 }
]);
}
}
Angular signals integrate directly with the template compiler. The framework detects signal reads in templates and sets up fine-grained subscriptions, eliminating the need for Zone.js patching of async APIs. With provideExperimentalZonelessChangeDetection(), Angular applications can drop Zone.js entirely, reducing bundle size by approximately 13KB gzipped.
Signal Inputs and Model Inputs
@Component({
selector: 'app-user-card',
template: `<div>{{ fullName() }}</div>`
})
export class UserCardComponent {
// Signal-based inputs
firstName = input.required<string>();
lastName = input<string>('');
// Two-way binding with model signals
selected = model(false);
// Computed from signal inputs
fullName = computed(() =>
`${this.firstName()} ${this.lastName()}`.trim()
);
}
Vue 3 Reactivity: Proxies and Refs
Vue's reactivity system predates the current signals wave, having shipped Proxy-based reactivity in Vue 3 back in 2020. While Vue does not use the term "signals" in its API, the underlying mechanism is functionally equivalent. Vue's ref() and reactive() create tracked state, computed() creates derived values, and watchEffect() creates effects.
Vue Composition API
<script setup>
import { ref, reactive, computed, watchEffect } from 'vue';
// ref() for primitive values - equivalent to a signal
const count = ref(0);
// reactive() for objects - deep proxy-based tracking
const cart = reactive({
items: [],
discount: 0
});
// computed() - lazy, cached derived state
const total = computed(() => {
const subtotal = cart.items.reduce((s, i) => s + i.price * i.qty, 0);
return subtotal * (1 - cart.discount);
});
// watchEffect() - auto-tracking side effect
watchEffect(() => {
document.title = `Cart (${cart.items.length}) - $${total.value.toFixed(2)}`;
});
function addToCart(product) {
const existing = cart.items.find(i => i.id === product.id);
if (existing) {
existing.qty++; // Mutation tracked by proxy
} else {
cart.items.push({ ...product, qty: 1 });
}
}
</script>
<template>
<div>
<p>Total: ${{ total.toFixed(2) }}</p>
<button @click="count++">Count: {{ count }}</button>
</div>
</template>
A key difference from SolidJS: Vue still uses a virtual DOM for rendering. However, the Vue compiler statically analyzes templates to generate optimized update paths that skip static content. The reactivity system determines when to re-render, while the compiler determines what to re-render, achieving near-signal-level efficiency in practice.
React: The Compiler Approach
React has taken a characteristically different path. Rather than adopting signals as a user-facing API, the React team built a compiler (React Compiler, formerly React Forget) that automatically memoizes components and hooks. The philosophy is that developers should write natural code and the compiler should optimize it.
React Without Manual Memoization
// Before: Manual optimization required
function TodoList({ todos, filter }) {
const filteredTodos = useMemo(
() => todos.filter(t => matchesFilter(t, filter)),
[todos, filter]
);
const handleToggle = useCallback(
(id) => dispatch({ type: 'toggle', id }),
[dispatch]
);
return (
<ul>
{filteredTodos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
/>
))}
</ul>
);
}
// After: React Compiler handles memoization automatically
function TodoList({ todos, filter }) {
const filteredTodos = todos.filter(t => matchesFilter(t, filter));
return (
<ul>
{filteredTodos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={(id) => dispatch({ type: 'toggle', id })}
/>
))}
</ul>
);
}
React's approach preserves its mental model where components are functions that return UI descriptions. The compiler inserts memoization boundaries automatically, avoiding unnecessary re-renders without requiring developers to reason about dependency arrays. This contrasts sharply with the signal-based approach where the reactive tracking is explicit in the API.
"Signals solve a performance problem that React chooses to solve with a compiler instead. Both approaches have merit, but they make fundamentally different tradeoffs about developer experience versus runtime behavior." — Dan Abramov
Framework Comparison: Reactivity Models
| Feature | SolidJS | Angular | Vue 3 | React |
|---|---|---|---|---|
| Reactivity Primitive | createSignal | signal() | ref() / reactive() | useState + Compiler |
| Derived State | createMemo | computed() | computed() | useMemo (auto) |
| Side Effects | createEffect | effect() | watchEffect() | useEffect |
| Tracking | Automatic (call-based) | Automatic (call-based) | Automatic (proxy) | Manual deps (auto via compiler) |
| Rendering | No VDOM | Incremental DOM | VDOM with patch optimization | VDOM with fiber reconciliation |
| SSR | Streaming | Built-in | Built-in | Server Components |
Performance Implications
The performance differences between these reactivity models become significant in specific scenarios. Fine-grained reactivity shines when you have large lists, frequent updates, or deeply nested component trees. Building high-performance UIs often draws on the same principles found in WebSocket-driven architectures where selective updates are critical for responsiveness.
Update Granularity Benchmark
Consider a dashboard with 1,000 data cells updating every 100 milliseconds. In a virtual DOM framework, each update triggers a component re-render, VDOM diff, and reconciliation pass. In a signal-based framework, each update directly modifies the single text node that changed:
// SolidJS: Each cell is a direct DOM binding
function DataCell({ row, col, data }) {
// This creates a single reactive subscription
// that updates one text node - no component re-render
return <td>{data[row][col]()}</td>;
}
// Angular: Signal-based cell updates
@Component({
selector: 'data-cell',
template: `<td>{{ value() }}</td>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DataCellComponent {
row = input.required<number>();
col = input.required<number>();
data = input.required<Signal<number>[][]>();
value = computed(() => this.data()[this.row()][this.col()]());
}
In benchmarks based on the js-framework-benchmark suite, SolidJS consistently outperforms React and Vue in row swapping, partial updates, and large list creation. Angular with signals closes the gap significantly compared to its Zone.js-based predecessor, showing 30-50% improvement in partial update scenarios.
Building a Real-World Example: Reactive Todo App
Let us build the same feature across frameworks to illustrate the practical differences. We will create a filtered, sortable todo list with persistence, which demonstrates patterns commonly used alongside well-designed RESTful APIs.
SolidJS Implementation
import { createSignal, createMemo, createEffect, For } from "solid-js";
import { createStore, produce } from "solid-js/store";
function TodoApp() {
const [todos, setTodos] = createStore([]);
const [filter, setFilter] = createSignal("all");
const [sortBy, setSortBy] = createSignal("created");
const filteredTodos = createMemo(() => {
let result = [...todos];
if (filter() === "active") {
result = result.filter(t => !t.completed);
} else if (filter() === "completed") {
result = result.filter(t => t.completed);
}
return result.sort((a, b) => {
if (sortBy() === "priority") return b.priority - a.priority;
return new Date(b.created) - new Date(a.created);
});
});
const stats = createMemo(() => ({
total: todos.length,
active: todos.filter(t => !t.completed).length,
completed: todos.filter(t => t.completed).length
}));
// Persist to localStorage reactively
createEffect(() => {
localStorage.setItem("todos", JSON.stringify(todos));
});
function addTodo(text, priority = 1) {
setTodos(produce(list => {
list.push({
id: crypto.randomUUID(),
text,
completed: false,
priority,
created: new Date().toISOString()
});
}));
}
function toggleTodo(id) {
setTodos(
t => t.id === id,
"completed",
completed => !completed
);
}
return (
<div>
<header>
<span>{stats().active} remaining</span>
</header>
<For each={filteredTodos()}>
{todo => (
<div onClick={() => toggleTodo(todo.id)}>
{todo.text} - {todo.completed ? "Done" : "Active"}
</div>
)}
</For>
</div>
);
}
Angular Implementation
@Component({
selector: 'app-todo',
template: `
<header>
<span>{{ activeCount() }} remaining</span>
</header>
@for (todo of filteredTodos(); track todo.id) {
<div (click)="toggle(todo.id)">
{{ todo.text }} - {{ todo.completed ? 'Done' : 'Active' }}
</div>
}
`
})
export class TodoComponent {
private todos = signal<Todo[]>([]);
filter = signal<'all' | 'active' | 'completed'>('all');
sortBy = signal<'created' | 'priority'>('created');
filteredTodos = computed(() => {
let result = this.todos();
if (this.filter() === 'active') result = result.filter(t => !t.completed);
if (this.filter() === 'completed') result = result.filter(t => t.completed);
return [...result].sort((a, b) =>
this.sortBy() === 'priority' ? b.priority - a.priority
: +new Date(b.created) - +new Date(a.created)
);
});
activeCount = computed(() =>
this.todos().filter(t => !t.completed).length
);
toggle(id: string) {
this.todos.update(list =>
list.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
);
}
}
Signals and Server-Side Rendering
The interaction between signals and SSR deserves special attention. Fine-grained reactivity creates a challenge for hydration: the server sends static HTML, but the client needs to reconstruct the reactive dependency graph. Each framework handles this differently.
SolidJS uses a technique called progressive hydration where signal subscriptions are lazily established as the user interacts with different parts of the page. Solid Start, its meta-framework, supports streaming SSR where chunks of HTML are sent as their data dependencies resolve.
Angular uses incremental hydration with @defer blocks, allowing parts of the page to hydrate on demand. The signal-based change detection makes this particularly efficient because the framework knows exactly which bindings need to be reactivated.
Vue's SSR hydration mismatch detection works with its Proxy-based reactivity to validate that the server-rendered HTML matches the client-side reactive state. Nuxt 3 adds island architecture on top, where only interactive components are hydrated.
Common Pitfalls and Anti-Patterns
Working with signals introduces its own class of bugs. Understanding these pitfalls is as important as understanding the API.
1. Destructuring Kills Reactivity
// WRONG - loses reactivity in SolidJS
function UserProfile(props) {
const { name, email } = props; // Snapshot, not reactive
return <div>{name}</div>; // Never updates
}
// CORRECT - preserve reactive access
function UserProfile(props) {
return <div>{props.name}</div>; // Reactive property access
}
// CORRECT in Vue - use toRefs
const props = defineProps<{ name: string; email: string }>();
const { name, email } = toRefs(props); // Each is still a ref
2. Untracked Access Outside Reactive Context
// WRONG - setTimeout breaks tracking
createEffect(() => {
setTimeout(() => {
console.log(count()); // Read outside reactive scope
}, 1000);
});
// CORRECT - capture value before async boundary
createEffect(() => {
const currentCount = count(); // Tracked read
setTimeout(() => {
console.log(currentCount); // Uses captured value
}, 1000);
});
3. Infinite Effect Loops
// DANGEROUS - effect writes to its own dependency
const [count, setCount] = createSignal(0);
createEffect(() => {
setCount(count() + 1); // Infinite loop!
});
// SAFE - use untrack or separate read/write
import { untrack } from "solid-js";
createEffect(() => {
const current = count();
untrack(() => {
// Process without triggering re-execution
logToAnalytics(current);
});
});
Choosing the Right Approach
The decision between signal-based frameworks depends on your project constraints, team experience, and performance requirements. Each approach has clear strengths worth considering alongside broader architectural decisions like those in clean architecture.
- Choose SolidJS if raw performance is paramount, your team is comfortable with a smaller ecosystem, and you want the most explicit control over reactivity.
- Choose Angular if you need enterprise features (dependency injection, forms, HTTP client, routing) and want signals integrated into a full-featured framework.
- Choose Vue if you want the gentlest learning curve, Proxy-based reactivity that handles deep objects naturally, and a proven ecosystem with Nuxt.
- Choose React if you rely on its vast ecosystem, server components architecture, or your team already has deep React expertise. The compiler mitigates most performance concerns.
The Future of Reactivity
The convergence toward signals is not a coincidence. The TC39 proposal for native JavaScript signals (currently at Stage 1) aims to provide a standard reactive primitive that all frameworks can build upon. If adopted, it would eliminate the need for framework-specific implementations while preserving the fine-grained tracking that makes signals performant.
Meanwhile, the boundaries between approaches continue to blur. React's compiler brings automatic optimization to its VDOM model. Vue's Vapor mode experiments with dropping the VDOM entirely for signal-like compilation. Angular explores ever-deeper signal integration across its entire API surface. The direction is clear: the future of frontend state management is reactive, fine-grained, and increasingly compiler-assisted.
Understanding these patterns deeply equips you to evaluate new frameworks and paradigms as they emerge. Whether you are optimizing an existing application or starting a greenfield project, signals represent the most important architectural shift in frontend development since the introduction of component-based UI libraries.