On the signal() and computed() page you saw what signals do. This page shows how they work on the inside: a reactive graph built automatically from reads, in which writes only push a notification, and recalculation happens only when someone reads. All the useful properties come from here: computed is lazy and memoized, you never see inconsistent intermediate states, and Angular can re-render only the affected components, without Zone.js.
computed costs nothing if nobody reads it, why items().push() does not update the UI, why an effect that sets another signal is a bad idea, or why a signal read inside an if branch sometimes “does not react”.Producers, consumers and the graph
Each signal is a node in the graph. signal() is a producer. computed() is both a consumer (it reads other signals) and a producer (others read it). effect() and component templates are consumers. While a reactive function runs there is an active consumer, and every signal read at that moment becomes its dependency. The graph is not declared: it is built from reads.
Each node has a version that increases only when it produces a value different from the previous one. Some consumers are “live” (effects and templates); for them, the graph also keeps reverse edges, from producer to consumer, so that it can send them notifications. The primitive types in @angular/core/primitives/signals show exactly these fields: version, dirty, producers, consumers.
const a = signal(1); // producer
const b = signal(2); // producer
const sum = computed(() => a() + b()); // consumer AND producer
const product = computed(() => a() * b());
const result = computed(() => sum() + product()); // diamond: a and b reach here by two paths
// While a reactive function runs, Angular keeps an "active consumer".
// Every signal read at that moment becomes its dependency. You declare nothing: the graph is built from reads.Push, then pull
A write (set/update) recalculates nothing. It only increases the signal’s version and tells the live consumers that they might be dirty: this is the push phase. When the consumer runs again (the template at the next render, the effect in its turn), it reads what it needs, and each computed checks its producers: has any version changed? Only then is it recalculated. This is the pull phase.
a.set(5)
│ PUSH: a increments its `version` and tells the "live" consumers (template, effect) that they MAY be dirty.
│ Nothing is recalculated yet.
▼
template / effect marked dirty → change detection schedules a render
│
▼
template reads result()
│ PULL: result checks its producers (sum, product) — do they have a different version?
│ sum checks its producers (a, b) — a has a different version → sum is recalculated.
│ If the new value is EQUAL to the old one, sum's version does not increase → propagation stops.
▼
result is recalculated once, with values that are already consistent (glitch-free)The important consequence: a computed is recalculated only after all writes have finished, with values that are already consistent. In the diamond of the demo, result never sees a new sum with an old product. This is called glitch-free. And if you change a and b in the same click, result is recalculated only once.
In the template: a = 1, b = 2 · result = 5
Laziness and memoization
The Angular documentation says it directly: a computed is evaluated lazily and memoized. The function does not run until the first read. Then the value is kept and reused until a dependency changes. In practice, you can put expensive derivations (filtering, sorting) in a computed: they cost something only when the result is actually needed.
const expensive = computed(() => {
console.log('computing…');
return items().filter(matches).sort(byPrice);
});
items.set(newItems); // nothing is shown: nobody has read yet
expensive(); // "computing…" — first read
expensive(); // nothing: memoized value
items.set(other); // still nothing — marked as stale, not recalculated
expensive(); // "computing…"Equality: where propagation stops
The version of a node increases only if the new value is not “equal” to the old one. By default, equality is Object.is: for objects and arrays, the reference matters, not the content. The same mechanism applies to a computed. If its result does not change, its consumers are not recalculated, even if its dependencies have changed. With { equal } you define what “the same” means.
// By default: Object.is. For objects/arrays, the REFERENCE matters.
const tags = signal(['a', 'b']);
tags.set(['a', 'b']); // different reference → "new" value → consumers recalculate
// Custom equality: the value does not change if it is "the same" by your rule
const sameItems = (x: string[], y: string[]) => x.length === y.length && x.every((v, i) => v === y[i]);
const stableTags = signal(['a', 'b'], { equal: sameItems });
stableTags.set(['a', 'b']); // equal → version does not increase → nothing downstream
// A computed also has equality: if its result is equal, propagation stops there
const parity = computed(() => (n() % 2 ? 'odd' : 'even')); // n: 0 → 2 → 4 … parity stays 'even'| Node | Value | Recalculations |
|---|---|---|
parity = computed(() => n() % 2 ? 'odd' : 'even') | ||
label = computed(() => 'The number is ' + parity()) |
| Signal | count = computed(tags().length) |
|---|---|
signal(...) — default equality (Object.is) | 2 · recalculated × |
signal(..., { equal: sameItems }) | 2 · recalculated × |
Dynamic dependencies
On every run, the list of producers of a consumer is rebuilt from what it actually read. A signal read only inside an if branch is a dependency only while that branch runs. This is a performance gain, but also a source of confusion: “I change details and nothing happens” is usually correct, because the current branch does not read it.
const view = computed(() => {
if (showDetails()) {
return details(); // dependencies: showDetails + details
}
return summary(); // dependencies: showDetails + summary
});
// On every run, the list of producers is rebuilt from what was EFFECTIVELY read.
// As long as showDetails() is false, changing details does not recalculate view.view() = summary v0
Dependencies recorded at the last run: · view recalculated ×
Effects and templates: the live consumers
Effects run asynchronously, during the change detection process, not at the moment of set(). The v22 documentation distinguishes two kinds. A view effect is created in the context of a component: it runs before that component is checked and is destroyed together with it. A root effect is created in a root-provided service: it runs before all components are checked and lives as long as the application. If a dependency changes while the effect is running, the effect runs once more before change detection continues.
// View effect: created in the context of a component → runs BEFORE that component
// is checked by change detection; it is destroyed together with the component.
@Component({ /* ... */ })
export class Profile {
constructor() {
effect(() => console.log('profile', this.user()));
}
}
// Root effect: created in a root-provided service → runs before all components are checked;
// it lives as long as the application.
@Injectable({ providedIn: 'root' })
export class Analytics {
constructor() {
effect(() => track(this.route()));
}
}
// Both are asynchronous (they do not run at the moment of set()), tied to the change detection process.A component’s template is also a reactive consumer. When a signal read in the template changes, Angular marks the component for checking (like markForCheck), and change detection re-renders only it and its ancestors. This is what zoneless applications and the default OnPush of v22 rely on (see Change detection).
@Component({
template: `<p>{{ fullName() }}</p>`, // the template is a reactive consumer
})
export class Card {
readonly first = signal('Ana');
readonly fullName = computed(() => `${this.first()} Pop`);
}
// first.set('Ioana') → the template's reactive node is marked dirty → the component is marked for
// checking (like markForCheck) → change detection re-renders it. No Zone.js, no checking
// of the whole tree: this is how zoneless applications work.When YES, when NO
- Expensive derivations in a
computed: they are calculated only when needed and once per change. - Chains of computeds instead of one huge computed. With equality cutoff, small changes stop early.
{ equal }for values that are often recreated but “the same” (filters, lists of ids from the server).untrackedfor reads that must not trigger anything (logging, analytics).
- It does not detect mutations:
pushon an array inside a signal notifies nobody. - It does not order your side effects: if you need strict ordering in time, use RxJS (see Advanced RxJS).
- It is not an event mechanism: “a click happened” is not state. Use
output()or a Subject. - It does not run effects synchronously: if you need the result immediately, derive it with
computed.
Common pitfalls
// ✗ Write inside a computed — error: "Writing to signals is not allowed in a `computed`"
const total = computed(() => {
count.set(items().length); // side effect in a derivation
return items().reduce(sum, 0);
});
// ✗ Effect used for derivation (one extra render, risk of loops)
effect(() => total.set(items().reduce(sum, 0)));
// ✓ The derivation is a computed
const total2 = computed(() => items().reduce(sum, 0));
// ✗ Mutation: same reference → Object.is says "equal" → nothing updates
items().push(newItem);
// ✓
items.update((list) => [...list, newItem]);- “It does not react”: check whether the signal is read on the branch that runs, and whether you read it with
(). A reference to a signal without calling it does not create a dependency. - Large objects in a single signal: any changed field recalculates everything that depends on the object. Split the state, or derive the fields with
computed. - An effect that writes to other signals: extra renders and a risk of loops. Almost always a
computedor a linkedSignal is the answer. - Reads in the constructor “so that it is ready”: a computed read too early is calculated with incomplete data. Leave it lazy.
Cheat sheet
NODE ROLE WHEN IT RUNS
signal producer — (it only changes its value and version on set/update)
computed consumer + producer lazy: on read, only if some dependency has changed
effect "live" consumer asynchronously, during change detection (view: before the component; root: before all)
template "live" consumer when the component is re-rendered after being marked dirty
RULES
dependencies = what you EFFECTIVELY read on the last run (dynamic, per branch)
default equality Object.is; { equal } for values that are "the same" → stops propagation
a computed without readers is not calculated at all
don't write signals in a computed; don't derive state with an effect
untracked(() => …) reads without creating a dependency