Why SolidJS, Angular, Vue, Svelte, and Qwik Each Implement Signals Differently — A Comparison of Frontend Reactivity APIs (2025)
At some point in frontend development, you find yourself thinking: "Why is this component re-rendering just because I clicked a button?" If you've used React, you know the experience of reflexively reaching for useMemo, useCallback, and React.memo, wrestling with bugs every time you get a dependency array wrong. At first I figured, "I guess that's just how it works," but when I encountered SolidJS for the first time, my thinking completely changed.
Signals are the idea of moving the unit of state management down from the "component" to the "value" itself. Because only the DOM nodes directly connected to a changed value get updated, unnecessary re-renders are structurally eliminated. In js-framework-benchmark scenarios, SolidJS can have dozens of times fewer DOM updates than React, and measurements have been reported showing UI updates 20–30% faster after Angular transitions to Zoneless. As of 2025, virtually every major framework has adopted this pattern.
This article is written primarily for frontend developers with some React experience. To be honest, without that background, the comparisons may not land well. By examining how each framework implements Signals and walking through the differences in real code, you'll come away with practical criteria for choosing a framework and applying these concepts in production.
Core Concepts
Pinning Down What Signals Are, Once and For All
Signals are made up of three elements.
| Element | Role |
|---|---|
| Signal | A reactive container that holds a value. Reading or writing it triggers subscriptions and notifications |
| Computed (Derived) | A read-only value derived from Signals. Automatically recalculated when its dependent Signals change |
| Effect | A side-effect function that runs in response to Signal value changes |
The key insight is that "dependencies are tracked automatically at runtime." Unlike React, you don't need to write [count] yourself. The moment you read a Signal, that act of reading registers itself as a subscription.
Virtual DOM vs. Fine-grained Reactivity: The Virtual DOM approach (React) re-executes the entire component function when state changes, then diffs it against the previous virtual DOM to apply updates to the real DOM. Signals-based Fine-grained Reactivity directly updates only the DOM nodes or computations that subscribe to the changed value. There is no intermediate comparison step.
Now that we understand the concept, let's put the real code side by side. It's the same logic across all of them: "create a counter, derive a doubled value, and log the changes."
// SolidJS
import { createSignal, createMemo, createEffect } from 'solid-js';
const [count, setCount] = createSignal(0);
const doubled = createMemo(() => count() * 2);
createEffect(() => console.log(`count: ${count()}`));// Angular 19+
import { signal, computed, effect } from '@angular/core';
const count = signal(0);
const doubled = computed(() => count() * 2);
effect(() => console.log(`count: ${count()}`));// Vue 3
import { ref, computed, watchEffect } from 'vue';
const count = ref(0);
const doubled = computed(() => count.value * 2);
watchEffect(() => console.log(`count: ${count.value}`));<!-- Svelte 5 -->
<script>
let count = $state(0);
let doubled = $derived(count * 2);
$effect(() => console.log(`count: ${count}`));
</script>// Preact Signals (also usable in React)
import { signal, computed, effect } from '@preact/signals-react';
const count = signal(0);
const doubled = computed(() => count.value * 2);
effect(() => console.log(`count: ${count.value}`));// Qwik
import { component$, useSignal, useComputed$, useTask$ } from '@builder.io/qwik';
export default component$(() => {
const count = useSignal(0);
const doubled = useComputed$(() => count.value * 2);
useTask$(({ track }) => {
track(() => count.value);
console.log(`count: ${count.value}`);
});
return <button onClick$={() => count.value++}>{count.value} / {doubled.value}</button>;
});Unlike the other frameworks, Qwik's Signals are designed to resume serialized server-side state on the client (Resumability). Because they handle reactive state that crosses the server-client boundary, their design purpose is somewhat different from the rest.
Svelte Runes: The
$state,$derived, and$effectintroduced in Svelte 5 are special syntax processed by the compiler. The compiler automatically generates the reactivity infrastructure code without boilerplate, while the actual dependency wiring happens at runtime. This lets developers maintain clean syntax while also reducing the runtime library size.
Differences in How Signal Values Are Read
Honestly, this was the most confusing part at first. Each framework has a different syntax for reading, so after learning one and moving to another, your fingers get tangled for a while.
| Framework | Reading | Writing |
|---|---|---|
| SolidJS | count() — function call |
setCount(1) |
| Angular | count() — function call |
count.set(1) / count.update(fn) |
| Vue 3 | count.value — property access |
count.value = 1 |
| Svelte 5 | count — directly like a variable |
count++ |
| Preact Signals | count.value — property access |
count.value = 1 |
| Qwik | count.value — property access |
count.value = 1 |
SolidJS and Angular call a function to read a Signal. That function call is the subscription signal saying "I depend on this Signal." Vue and Preact do the same thing via a getter on the .value property. Svelte lets the compiler handle it in the middle, so you can use it like a plain variable.
Practical Application
Example 1: SolidJS — A World Where Components Run Only Once
SolidJS is a case where the entire framework is built on top of Signals. It looks similar to React, but works in a fundamentally different way. The most shocking thing when I first encountered it was this: a SolidJS component function runs exactly once across the entire app.
import { createSignal } from 'solid-js';
function Counter() {
console.log('this line runs exactly once');
const [count, setCount] = createSignal(0);
return <button onClick={() => setCount(c => c + 1)}>{count()}</button>;
}Even if you click the button 100 times, the console.log prints only once. In React, the entire component function re-runs every time count changes. That difference is the essence of SolidJS. Now let's move on to a more practical example.
import { createSignal, createMemo, createEffect, For } from 'solid-js';
interface Todo {
text: string;
done: boolean;
}
function TodoList() {
const [todos, setTodos] = createSignal<Todo[]>([]);
const [filter, setFilter] = createSignal('all');
const filtered = createMemo(() => {
if (filter() === 'all') return todos();
return todos().filter(t => t.done === (filter() === 'done'));
});
createEffect(() => {
// only re-runs when todos changes, not when filter changes
console.log(`todo count: ${todos().length}`);
});
return (
<div>
<select onChange={e => setFilter(e.target.value)}>
<option value="all">All</option>
<option value="done">Done</option>
</select>
<For each={filtered()}>
{todo => <div>{todo.text}</div>}
</For>
</div>
);
}| Point | Explanation |
|---|---|
TodoList function |
Runs exactly once when the app starts |
createMemo |
Automatically recalculates whenever either filter or todos changes |
createEffect |
Since it only reads todos(), it does not react to filter changes |
<For> |
Updates the DOM at the level of individual array items. Does not redraw the entire list |
Example 2: Angular Zoneless — Clean Without Zone.js
This is the Zoneless architecture that was offered experimentally starting in Angular 18, then became the recommended default in Angular 19/20. The traditional Angular used Zone.js to intercept async events and trigger change detection. Put simply, on every click, Angular would ask the whole app "did anything change?" With the move to Signals, that responsibility shifts to the Signals themselves — if no Signal changed, detection is skipped entirely.
import { Component, signal, computed, effect } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
@Component({
selector: 'app-cart',
imports: [CurrencyPipe],
template: `
<div>
<p>Item count: {{ itemCount() }}</p>
<p>Total: {{ totalPrice() | currency:'KRW' }}</p>
<button (click)="addItem()">Add</button>
</div>
`
})
export class CartComponent {
items = signal<{ name: string; price: number }[]>([]);
itemCount = computed(() => this.items().length);
totalPrice = computed(() =>
this.items().reduce((sum, item) => sum + item.price, 0)
);
constructor() {
effect(() => {
localStorage.setItem('cart', JSON.stringify(this.items()));
});
}
addItem() {
this.items.update(prev => [...prev, { name: 'New Item', price: 10000 }]);
}
}| Point | Explanation |
|---|---|
signal() |
Angular's reactive state. Use set() to replace the value or update(fn) to change it based on the previous value |
computed() |
Recalculates itemCount and totalPrice only when items changes |
effect() |
When declared inside the constructor, it is automatically cleaned up when the component is destroyed |
| Zone.js not needed | Zone.js can be removed with a single provideExperimentalZonelessChangeDetection() call |
Example 3: Grafting Signals onto a React App with Preact Signals
If you're not in a position to migrate to a new framework, you can attach @preact/signals-react to an existing React app.
pnpm add @preact/signals-reactimport { signal, computed } from '@preact/signals-react';
// module top-level — reactive state that lives outside components
const cartItems = signal<string[]>([]);
const cartCount = computed(() => cartItems.value.length);
function CartBadge() {
// Signal object passed directly to JSX — DOM updates without re-rendering the component
return <span>{cartCount}</span>;
}
function AddButton() {
return (
<button onClick={() => {
cartItems.value = [...cartItems.value, 'New Item'];
}}>
Add to Cart
</button>
);
}When you pass a Signal object directly into JSX like <span>{cartCount}</span>, the Signal updates only that DOM node directly without re-rendering the component. This is special syntax that only works with @preact/signals-react; to read a Signal value in a regular React component, you must go through .value.
Changing cartItems will only update CartBadge. AddButton will not re-render.
Caution: Retrofitting Signals into React's Virtual DOM can actually add complexity rather than reduce it. Preact Signals handles this internally, but large-scale apps require architectural-level design. Rather than reaching for it just because "it's supposed to be faster," it's recommended to try applying it selectively to component trees where re-rendering bottlenecks have actually been confirmed.
Example 4: Svelte 5 Runes — The Compiler Handles Everything
Svelte 5 has the compiler transform $state, $derived, and $effect into optimized reactivity code. The compiler generates the boilerplate and the actual dependency wiring happens at runtime, so from the developer's perspective, you can write intuitive code as if you're just using plain variables.
<script>
let count = $state(0);
let step = $state(1);
let doubled = $derived(count * 2);
let message = $derived(`Current value: ${count}, doubled: ${doubled}`);
$effect(() => {
if (count >= 10) {
console.warn('The value has gotten too large!');
}
});
</script>
<button onclick={() => count += step}>+{step}</button>
<input type="number" bind:value={step} />
<p>{message}</p>You can use the same Runes in .svelte.js files as well, so shared reactive logic outside of components can be written just as naturally.
// store.svelte.js — shared reactive state outside components
export function createCounter(initial = 0) {
let count = $state(initial);
let doubled = $derived(count * 2);
return {
get count() { return count; },
get doubled() { return doubled; },
increment: () => count++,
reset: () => count = initial,
};
}Pros and Cons
Advantages
Honestly, the biggest thing I felt after working with Signals was the sense that "optimization is already built in." The time spent wondering whether to add useMemo or whether useCallback is needed just disappears.
| Item | Details |
|---|---|
| Fine-grained performance optimization | Only the DOM nodes and computations that read a changed value are updated. Unnecessary re-renders are structurally eliminated |
| No manual optimization needed | Optimization works without boilerplate like useMemo, useCallback, or React.memo |
| Usable outside components | Not tied to lifecycle, so reactive state can be created anywhere in a module |
| Bundle size | Very lightweight — SolidJS is about 7.6KB, Preact Signals about 1KB |
| Automatic dependency tracking | No need to manage dependency arrays manually. The runtime tracks them automatically |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Mental model shift | You need to shift from component-centric thinking to value-centric thinking | Start by refactoring small units (counters, form fields) to Signals to build up the intuition gradually |
| Inconsistent APIs across frameworks | SolidJS uses count(), Angular uses count(), Vue/Preact use count.value, Svelte uses count — all different |
Learn the pattern thoroughly in one framework before expanding to others |
| React retrofit problems | Retrofitting Signals into React's Virtual DOM can lower average performance | Recommended to apply selectively only to components where re-rendering bottlenecks have been confirmed |
| Immature debugging tooling | The debugging ecosystem (DevTools, etc.) is less mature than Virtual DOM approaches | You can manually log inside effect to inspect the dependency tracking flow |
| TC39 standard uncertainty | Still at Stage 1, so the spec may change | Prefer each framework's native API and treat the standard polyfill as experimental only |
TC39 Signals Proposal: An official proposal to add Signals to the JavaScript language standard. Maintainers from Angular, Solid, Vue, Svelte, Preact, Qwik, MobX, and others are collaborating on the design. It entered Stage 1 in April 2024, and a polyfill is already publicly available, though production use is still premature.
The Most Common Mistakes in Practice
-
Reading a Signal conditionally inside an Effect — If you read a Signal inside a condition like
if (flag()) { count(); }, whenflagisfalse, thecountsubscription never gets registered, breaking dependency tracking. I once spent a long time puzzling over "why isn't the effect running even though the value is changing?" — that was exactly this situation with a conditionalSignalread insidecreateEffect. It is safer to read the value outside the condition first. -
Using Effect instead of Computed — Some people create derived values with
effect(() => { derivedValue = count() * 2; }), but this approach has no memoization and re-runs every time. Always usecomputed/createMemofor read-only derived values. -
Splitting Signals too granularly — Separating all related state into individual Signals actually makes management more complex. Values that change together are better grouped into a single object Signal, like
signal({ x: 0, y: 0 }).
Closing Thoughts
Ultimately, in any framework, a subscription occurs the moment you read a Signal. Whether the syntax is a function call, a .value property, or compiler magic, the essence is the same. Understanding this pattern lets you intuitively grasp why code re-renders and where to optimize.
Three steps you can take right now:
-
Spend about 30 minutes browsing the SolidJS official tutorial (solidjs.com/tutorial). It runs directly in the browser, and you can quickly internalize the feeling of "a component runs only once." No installation required.
-
Try creating one Signal-based piece of state in the framework you're currently using. In Vue, try declaring a derived value with
watchEffectorcomputed; in Angular, try declaring a derived value withcomputedinstead ofngOnChanges. The difference becomes immediately apparent. -
In a React project, install
@preact/signals-react(pnpm add @preact/signals-react) and try attaching Signals to a component that re-renders frequently. Use React DevTools' "Highlight updates" feature to compare before and after — the effect is visible to the eye.
References
- TC39 Signals Proposal | GitHub
- A TC39 Proposal for Signals | EisenbergEffect (Medium)
- Fine-grained reactivity | SolidJS official docs
- Signals in Modern Frontend: Preact, Solid.js, Qwik | DEV Community
- Optimizing JavaScript Delivery: Signals v React Compiler | RedMonk
- Reactivity Models Compared: React, Vue, Angular, Svelte | OpenReplay
- Vue Reactivity in Depth | Vue official docs
- Preact Signals Guide | Preact official docs
- Angular Reactivity with Signals | Angular GitHub Discussion
- Signals: Fine-grained Reactivity for JavaScript Frameworks | SitePoint