skip to content

In Angular, what happens in the signal graph when you call set() on a signal: which work is pushed immediately and which is pulled later?

level: middleimportance: must knowfreq 52%

answer

  1. two phases per write
  2. notification, not computation
  3. live consumers get marked dirty
  4. versions compared on read
  5. computed runs only when read

basics

~20 s

A set() that changes the value only pushes a dirty mark through the graph's live consumers; no computed or effect runs then. Recomputation is pulled later, when something reads a computed, and only if a producer's version changed.

solid answer

~40 s

`set()` first compares the new value with the old one using the signal's equality function (`Object.is` by default); if they are equal, nothing happens. Otherwise Angular stores the value, bumps the signal's `version` and a global epoch, and **pushes** a dirty notification to its live consumers — computeds that feed effects or templates, the effects themselves, and component template consumers — transitively. That phase only marks nodes; no computation runs. The **pull** phase comes later: when a template refresh, an effect or your code reads a computed, the computed checks its producers' versions, recomputes only if one of them actually changed, and bumps its own version only if the new value is not equal to the old one. Effects run when the framework schedules them, not inside `set()`.

code

ts · 25 lines
ts
import { computed, signal } from '@angular/core';

const firstName = signal('Ada');
const lastName = signal('Lovelace');

let fullNameRuns = 0;
let greetingRuns = 0;

const fullName = computed(() => {
  fullNameRuns++;
  return `${firstName()} ${lastName()}`;
});

const greeting = computed(() => {
  greetingRuns++;
  return `Hi ${firstName()}, your badge reads ${fullName()}`;
});

greeting();              // fullNameRuns = 1, greetingRuns = 1

firstName.set('Grace');  // push: nothing recomputes yet
lastName.set('Hopper');  // push again: still nothing recomputes

greeting();              // pull: 'Hi Grace, your badge reads Grace Hopper'
                         // fullNameRuns = 2, greetingRuns = 2

go deeper

for a junior

Recall that writing a signal only marks things as stale and that computed values are worked out when they are read.

for a middle

Walk through the push of dirty marks and the version-based pull, and explain why effects and computeds do not run inside set().

for a senior

Use the model to predict how many times computations run after several writes, and to explain live versus non-live nodes and their memory behaviour.

for a principal

Explain how push-pull shapes guidance for the team: cheap writes, lazy derivations, and why batching helpers are unnecessary in Angular code.

## The graph behind the API Angular's signals — `signal()`, `computed()`, `effect()`, `linkedSignal()` and the templates that read them — are nodes in a **reactive graph** implemented in `@angular/core/primitives/signals`. Every node is a `ReactiveNode` and plays one or both roles: - a **producer** delivers values: a writable signal, a computed, a linkedSignal; - a **consumer** reads producers inside a reactive context: a computed, an effect, a component's template consumer. A computed is both. Consumers always record which producers they read. Producers record the reverse edge only for **live** consumers — effects and template consumers, which are always live, and any computed that a live consumer depends on. ## Phase 1: the push, inside `set()` When code calls `firstName.set('Grace')`, Angular does this synchronously: 1. Compares the new value with the old using the signal's `equal` function (`Object.is` unless you supplied one). If equal, it stops here: no version bump, no notification. 2. Stores the value and increments the signal's **`version`**. 3. Increments a global **epoch** counter, which records that *some* source signal changed. 4. Walks the producer-to-consumer edges and marks every live consumer **dirty**, transitively. A computed that is marked passes the mark on to its own live consumers. No computed recomputes and no effect body runs during this walk. Reading a signal while it is in progress is treated as an assertion error. By the end of the walk, dirty effects have been scheduled to run and template consumers have flagged their views so change detection will visit them. ## Phase 2: the pull, on read Recomputation happens when a value is **read**. Calling `fullName()`: 1. Skips all work if the computed is live and not dirty, or if it last verified itself at the current epoch. 2. Otherwise **polls** its producers in the order it read them last time, comparing each producer's current `version` with the version it recorded. A producer that is itself a stale computed is brought up to date first — polling is recursive. 3. If no version differs, the cached value is still correct: the computed marks itself clean without running. 4. If one differs, it reruns its function, rebuilding its dependency list from the reads it makes this time. 5. It compares the new value with the old using its `equal` function; only a **different** value bumps its `version`. | | Push phase | Pull phase | | --- | --- | --- | | Triggered by | `set()` or `update()` changing a value | Reading a computed, running an effect, refreshing a template | | Work done | Marks live consumers dirty | Polls versions, recomputes where needed | | Runs user code | No | Yes: computations, effect bodies, templates | | Timing | Synchronous, inside the write | When the value is read or the scheduler runs | ## Why it is built this way - **Laziness.** A computed starts with no value and computes on first read. A computed nobody reads never runs, however often its inputs change. - **Batching without an API.** Two writes in a row mark the same nodes twice but compute nothing, so the next read recomputes once. Angular has no `batch()` function because it does not need one. - **Consistency.** Because nothing reruns until every mark has landed, a reader never mixes old and new values of the same write. - **Memory.** A computed read only from plain code, never from an effect or template, is not live; producers hold no reference to it, so it can be garbage-collected. It stays correct by polling versions and the epoch on each read. ## What this means in code The code example counts runs: two writes followed by one read recompute `fullName` and `greeting` once each. A computed that is never read after the writes does not run at all. Effects that depend on the chain run later, in Angular's scheduled flush, and read already-consistent values.

  • Why can setting a signal to the same value be completely free?
    `set()` first runs the signal's equality function, `Object.is` by default. If the new value is equal, Angular returns before bumping the version or epoch and before notifying anyone, so no consumer is marked dirty and nothing downstream is checked.
  • What makes a computed live, and why does it matter?
    A computed becomes live when a live consumer — an effect or a template — depends on it, directly or through other computeds. Live nodes receive push notifications; non-live ones are not referenced by their producers and instead poll versions and the global epoch when read, which lets them be garbage-collected when dropped.
  • Does calling set() inside a loop 1,000 times recompute a dependent computed 1,000 times?
    No. Each changing write bumps versions and pushes marks, but no computed runs during the push. The computed recomputes once when it is next read, using the final value.

Like a newsroom where a changed fact only puts a 'check me' sticker on each affected article; nobody rewrites an article until an editor actually opens it, and then they rewrite it only if the fact really changed.

saying these in an interview costs you the question

  • Calling set() immediately recomputes every computed that depends on the signal.
  • Effects run synchronously inside the set() call.
  • Every consumer, live or not, receives a push notification.
  • Angular needs a batch() call to group several writes.
  • A computed recomputes on every read, like a plain getter.