skip to content

In Angular, how does reading a signal in a component's template make that view refresh after a write, and which views does the write mark?

level: seniorimportance: should knowfreq 42%

answer

  1. one consumer per component view
  2. embedded views share it
  3. always-live template consumer
  4. ancestors marked for traversal only
  5. no markForCheck needed

basics

~20 s

Each component view gets a live template consumer that records the signals its template reads. A write marks that consumer dirty, notifies the change-detection scheduler and flags ancestors only for traversal, so the next pass refreshes that component without re-rendering its OnPush parents.

solid answer

~40 s

While Angular executes a component's template, that view's **reactive template consumer** is the active consumer, so every signal read — in bindings, `@if`/`@for` expressions, host bindings, or methods the template calls — is recorded. Embedded views created by control-flow blocks share the component's consumer, so tracking is per component. The consumer is always live. When a recorded signal changes, the consumer is marked dirty and calls `markAncestorsForTraversal`: it notifies the scheduler and sets a has-child-views-to-refresh flag on each ancestor up to the root. On the next pass Angular walks down through OnPush ancestors without refreshing them and refreshes the marked component, if polling confirms a producer really changed. That is why signals need no `markForCheck()` and suit OnPush, the default in Angular 22, and zoneless apps.

code

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

@Component({
  selector: 'app-greeting-card',
  template: `
    <h2>{{ greeting() }}</h2>
    <button (click)="rename()">Rename</button>
  `,
})
export class GreetingCard {
  readonly firstName = signal('Ada');
  readonly lastName = signal('Lovelace');
  readonly fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
  readonly greeting = computed(() => `Hi ${this.firstName()}, your badge reads ${this.fullName()}`);

  rename(): void {
    // Marks this view's template consumer dirty and flags its ancestors for traversal.
    // No markForCheck() is needed, even though the component is OnPush by default.
    this.firstName.set('Grace');
  }
}

go deeper

for a junior

Recall that reading a signal in a template is enough for that component to update when the signal changes, with no manual marking.

for a middle

Explain the per-component template consumer, which reads it records, and how embedded views share it.

for a senior

Explain marking for traversal versus refresh, predict which views refresh after a write, and choose component boundaries to control refresh granularity.

for a principal

Plan how a codebase moves from zone-triggered, markForCheck-heavy components to signal-read templates, and what granularity the team should design for.

## Templates are consumers In Angular's reactive graph a component template is a **consumer**, just like `computed()` or `effect()`. The node is a `ReactiveLViewConsumer` of kind `'template'`, stored on the view's internal data. It has three properties that matter: - It is **always live**: producers keep a reference to it and push dirty marks to it. - It is created the first time the template reads a signal, and reused afterwards. - It is **per component view**. Embedded views — the content of `@if`, `@for`, `@switch` and `<ng-template>` instantiations — share their component's consumer, so a signal read inside an `@for` row is tracked by the component that owns the template. Root views get one as well, for the root component's host bindings. This answer assumes Angular 22.2, where components default to `OnPush` and applications default to zoneless change detection. ## What gets tracked While Angular refreshes the view, the template consumer is the **active consumer**. Every signal read that happens synchronously during that refresh is recorded: - interpolations such as `{{ greeting() }}`; - property and attribute bindings, `@if` and `@for` expressions; - host bindings, which are evaluated while the view containing the host element refreshes; - signals read inside a component method called from the template, because the method runs during the refresh. Event handlers such as `(click)` are **not** part of the refresh and create no dependencies. ## What a write does When `firstName.set('Grace')` changes a signal the template read — directly or through computeds such as `fullName` and `greeting` — the push reaches the template consumer: 1. The consumer is marked **dirty**. 2. Its hook calls `markAncestorsForTraversal(lView)`, which **notifies the change-detection scheduler** so a pass is scheduled even with no zone.js. 3. It walks up the parent views setting a **has-child-views-to-refresh** flag, stopping early at an ancestor that already has it. Nothing is rendered yet; the write only leaves a trail. ## What the next pass does During change detection, Angular walks from the root: - An `OnPush` ancestor carrying only the traversal flag is **not refreshed**: its bindings are not re-evaluated; Angular descends into its children. (An `Eager` component, the pre-v22 default, still refreshes on every pass, as it always did.) - At the marked component, the dirty consumer is **polled**. If a producer's version changed, the view is refreshed; if every computed on the path produced an equal value, it is skipped. - `OnPush` siblings with no flags are not refreshed. | Mechanism | Views marked | Views refreshed | | --- | --- | --- | | Signal read by template changes | The component; ancestors for traversal only | The component, if a producer changed (plus any `Eager` views) | | `ChangeDetectorRef.markForCheck()` | The component and every ancestor as dirty | The component and its dirty ancestors | The second row is shown for contrast; the traversal rules for each change-detection strategy belong to the change-detection topics. ## Consequences in practice - **No manual marking.** A component that reads its state as signals in the template refreshes itself after a write, even under `OnPush`, and without zone.js. - **Targeted updates.** A counter in a deeply nested child refreshes that child, not the page shell above it. - **Granularity is the component.** Because embedded views share a consumer, one changed signal inside one `@for` row refreshes the whole component's template, not just that row. Splitting a heavy row into its own component makes it its own consumer. - **Reads must happen during the refresh.** A value copied from a signal into a plain field in `ngOnInit` or a subscription callback is not tracked; reading the signal in the template is. ## Common misconceptions - "The parent re-renders because the child's signal changed." `OnPush` ancestors are traversed, not refreshed. - "Signals update the DOM node directly without change detection." The view is still refreshed by change detection; signals decide *which* views need it.

  • Why does changing a signal read inside one @for row refresh the whole component template?
    Embedded views created by `@for` share the component's single template consumer, so the dirty mark lands on the component. Angular refreshes the component view, re-evaluating every row's bindings, and only bindings whose values changed touch the DOM. Moving the row into a child component gives it its own consumer.
  • Is a signal read in a component method called from the template tracked?
    Yes. The method runs synchronously while the template refreshes, when the template consumer is the active consumer, so its signal reads become dependencies of that view.
  • What notifies change detection to run in a zoneless application after the write?
    Marking the template consumer dirty calls `markAncestorsForTraversal`, which calls `notify()` on the application's change-detection scheduler. The scheduler then runs a pass, with no zone.js involved.

saying these in an interview costs you the question

  • A signal write marks every ancestor dirty, just as markForCheck() does.
  • Every @if and @for block gets its own reactive consumer.
  • Signals update the DOM directly, bypassing change detection.
  • OnPush components need markForCheck() after a signal write.
  • A value copied from a signal into a field in ngOnInit stays tracked.