Signal Primitives: Signal, Computed, Effect
Three Primitives to Rule Them All
Every signal system -- Solid, Vue, Angular, Preact, the TC39 proposal -- is built from exactly three primitives. If you deeply understand these three, you understand all of them. The APIs differ, the names differ, but the mechanics are identical.
- Signal -- a reactive value you can read and write
- Computed -- a derived value that auto-updates when its dependencies change
- Effect -- a side effect that re-runs when its dependencies change
That's it. Every reactive UI framework is built from these three Lego bricks arranged in a directed acyclic graph.
const count = signal(0);
const doubled = computed(() => count.value * 2);
effect(() => console.log(`Doubled: ${doubled.value}`));
count.value = 5;
The Mental Model
Think of a spreadsheet. Cell A1 holds a raw value (42) -- that's a signal. Cell B1 has a formula =A1 * 2 -- that's a computed. A chart that visualizes B1 -- that's an effect. When you change A1, B1 recalculates automatically, and the chart redraws. You never manually tell B1 to update. The spreadsheet tracks which cells depend on which, and propagates changes through the graph.
Signal systems work exactly the same way. The spreadsheet is the dependency graph. Cells are signals and computeds. Charts are effects. The "spreadsheet engine" is the reactive runtime.
Primitive 1: Signal (Reactive State)
A signal is the simplest reactive primitive: a container for a value that notifies subscribers when it changes.
// Every framework's signal, conceptually:
const temperature = signal(72);
// Read the value
console.log(temperature.value);
// Write a new value
temperature.value = 75;
Under the hood, a signal does two things:
- On read: if there's an active tracking context (a running computed or effect), register that context as a subscriber
- On write: notify all subscribers that the value changed
That's the entire API. The magic is in what happens at read time.
// Simplified signal implementation
function signal(initialValue) {
let value = initialValue;
const subscribers = new Set();
return {
get value() {
if (activeComputation) {
subscribers.add(activeComputation);
}
return value;
},
set value(newValue) {
if (newValue !== value) {
value = newValue;
for (const sub of subscribers) {
sub.notify();
}
}
}
};
}
The activeComputation variable is a global (module-scoped) reference to whatever computed or effect is currently executing. This is the "tracking context" pattern. We'll explore it deeply in the automatic dependency tracking topic.
Signal equality
Signals only notify subscribers when the value actually changes. Most implementations use Object.is for comparison:
const count = signal(0);
count.value = 0; // No notification -- same value
count.value = 1; // Notification -- value changed
const user = signal({ name: 'Alice' });
user.value = { name: 'Alice' }; // Notification! Different object reference
Object signals trigger updates on reference change, not deep equality. Setting user.value = { name: 'Alice' } creates a new object, so Object.is returns false even though the contents are identical. This is the same behavior as React's useState with objects. If you need deep reactivity on object properties, you need something like Vue's reactive() (Proxy-based) or Solid's stores.
Primitive 2: Computed (Derived State)
A computed is a value derived from other reactive values. It's lazy (only evaluates when read), cached (reuses the last value if dependencies haven't changed), and automatically tracks its dependencies.
const firstName = signal('Alice');
const lastName = signal('Smith');
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`;
});
console.log(fullName.value);
// The callback ran, tracking firstName and lastName as dependencies
// Result: 'Alice Smith'
console.log(fullName.value);
// Dependencies haven't changed, so returns cached value
// The callback did NOT run again
firstName.value = 'Bob';
console.log(fullName.value);
// firstName changed, so the callback runs again
// Result: 'Bob Smith'
Lazy evaluation
This is crucial: computeds don't eagerly recalculate when dependencies change. They mark themselves as "dirty" and wait until someone reads their value. This means if nobody reads fullName after firstName changes, the computation never runs.
const count = signal(0);
const expensive = computed(() => {
// Imagine this takes 100ms
return heavyCalculation(count.value);
});
count.value = 1; // expensive is now "dirty" but hasn't recalculated
count.value = 2; // still dirty, still hasn't recalculated
count.value = 3; // still dirty
console.log(expensive.value); // NOW it recalculates, using count = 3
// The heavy calculation ran exactly once, not three times
This lazy evaluation is a huge performance win. If a computed's value is only needed in certain UI branches (conditional rendering), it never wastes work when that branch is hidden.
Computeds as signals
A computed acts as a signal to anything that reads it. It can have its own subscribers:
const a = signal(1);
const b = computed(() => a.value * 2);
const c = computed(() => b.value + 10);
effect(() => console.log(c.value));
The dependency graph: a → b → c → effect. When a changes, the system walks the graph: mark b dirty, mark c dirty, re-run the effect (which reads c, which reads b, which reads a).
Primitive 3: Effect (Side Effects)
An effect is a computation that performs side effects and re-runs when its dependencies change. Unlike computeds, effects are eager -- they execute immediately and re-execute whenever a dependency changes.
const theme = signal('dark');
effect(() => {
document.body.className = theme.value;
});
// Immediately executes: body.className = 'dark'
theme.value = 'light';
// Effect re-executes: body.className = 'light'
Effects are the bridge between the reactive world and the imperative world. They're how signals connect to the DOM, to APIs, to anything outside the reactive graph.
Effect cleanup
Effects often need to clean up previous side effects before re-executing. Every signal framework provides a cleanup mechanism:
effect((onCleanup) => {
const id = setInterval(() => {
console.log(`Polling for user ${userId.value}`);
}, 1000);
onCleanup(() => clearInterval(id));
});
When userId changes, the cleanup from the previous execution runs first (clearing the old interval), then the effect re-executes (starting a new interval for the new user).
Effects vs Computeds
This distinction trips people up. Here's the rule:
- 1Use computed when you are deriving a value from other reactive values -- it is lazy, cached, and has no side effects
- 2Use effect when you need to perform a side effect (DOM mutation, API call, logging) -- it is eager and re-runs immediately
- 3Never use an effect to derive a value and store it in a signal -- that is what computed is for
- 4Effects are the leaves of the dependency graph, computeds are intermediate nodes
// WRONG: using effect to derive a value
const count = signal(0);
const doubled = signal(0);
effect(() => {
doubled.value = count.value * 2;
});
// RIGHT: using computed to derive a value
const count = signal(0);
const doubled = computed(() => count.value * 2);
The effect version creates an unnecessary subscription loop, runs eagerly even if nothing reads doubled, and can cause glitches (more on that next).
| What developers do | What they should do |
|---|---|
| Using effect to sync two signals Effects are for side effects (DOM, network, logging). Derived values should use computed for lazy evaluation and automatic caching | Use computed to derive one value from another |
| Creating a computed that performs side effects Computeds are lazy -- they only run when read. A side effect inside a computed may never execute or may execute at unexpected times | Use effect for side effects, computed for pure derivations |
| Assuming computed recalculates on every dependency change Lazy evaluation is a core feature. Multiple rapid dependency changes result in a single recalculation when finally read | Computed marks itself dirty and waits for a read |
Glitch-Free Execution
A "glitch" is when a computation sees an inconsistent state -- some dependencies have updated and others haven't yet. Consider:
const a = signal(1);
const b = computed(() => a.value * 2);
const c = computed(() => a.value + b.value);
effect(() => {
console.log(`a=${a.value}, b=${b.value}, c=${c.value}`);
});
When a.value = 2, what should happen?
bshould become 4 (2 * 2)cshould become 6 (2 + 4)- The effect should log
a=2, b=4, c=6
Without glitch-free execution, the effect might see a=2, b=2, c=4 (old b, partially updated c). That's a glitch.
Topological sorting
Signal systems prevent glitches using topological sorting. Before executing any computations after a signal change, the system:
- Marks all downstream nodes as "dirty"
- Sorts them by depth in the dependency graph (shallow first)
- Evaluates in order: nodes closer to the changed signal first
This topological ordering guarantees that by the time any node evaluates, all its dependencies have already been updated. The graph is always in a consistent state.
Push vs Pull: the hybrid approach
Pure push systems (like RxJS Observables) propagate values eagerly from source to sink. Pure pull systems evaluate lazily from sink to source. Modern signal systems use a hybrid:
Push the notification, pull the value.
When a signal changes, it pushes a "dirty" flag through the graph (cheap -- just setting a boolean). When something needs a value, it pulls by calling the derivation function (potentially expensive). This means:
- Unused branches stay dormant (pull benefit)
- Changed branches are known immediately (push benefit)
- Multiple rapid changes result in a single evaluation per node (pull benefit)
- The notification path is O(graph edges), not O(computation cost) (push benefit)
This push-pull hybrid is why signals are fundamentally more efficient than pure push (RxJS) or pure pull (polling) for UI reactivity.
How the Three Primitives Build a UI
Let's trace how these primitives compose into a complete reactive UI:
// State layer: signals
const todos = signal([
{ id: 1, text: 'Learn signals', done: false },
{ id: 2, text: 'Build app', done: false }
]);
const filter = signal('all');
// Derived layer: computeds
const filteredTodos = computed(() => {
const list = todos.value;
const f = filter.value;
if (f === 'all') return list;
if (f === 'active') return list.filter(t => !t.done);
return list.filter(t => t.done);
});
const stats = computed(() => {
const list = todos.value;
return {
total: list.length,
done: list.filter(t => t.done).length,
remaining: list.filter(t => !t.done).length
};
});
// Effect layer: DOM updates
effect(() => {
renderTodoList(filteredTodos.value);
});
effect(() => {
renderStats(stats.value);
});
When filter.value = 'active':
filteredTodosrecalculates (it depends onfilter) → the list effect re-rendersstatsdoes NOT recalculate (it only depends ontodos, notfilter) → the stats effect does NOT re-run
Changing the filter only updates the list display. The stats display is completely untouched. No memo needed. No useCallback. No useMemo. The granularity is automatic.
Across Frameworks
The three primitives map directly to every major signal-based framework:
| Concept | Solid | Vue 3 | Angular | Preact Signals | TC39 Proposal |
|---|---|---|---|---|---|
| Signal | createSignal() | ref() | signal() | signal() | Signal.State() |
| Computed | createMemo() | computed() | computed() | computed() | Signal.Computed() |
| Effect | createEffect() | watchEffect() | effect() | effect() | Watcher API |
| Batch updates | batch() | Automatic | Automatic | batch() | Planned |
| Deep reactivity | createStore() | reactive() | No (immutable) | No (immutable) | Not in scope |
The naming differs, the ergonomics differ, but the underlying model is the same three primitives connected in a dependency graph with glitch-free propagation.