skip to content

In Angular, when a signal read in an OnPush component's template changes, which views does the next pass re-check, compared with markForCheck()?

level: seniorimportance: nice to knowfreq 30%

answer

  1. each template is a consumer
  2. traverse versus refresh
  3. ancestors walked, not re-run
  4. markForCheck dirties the path

basics

~20 s

Only the view whose template read the signal is refreshed; its ancestors are flagged just so the pass can reach it, so OnPush ancestors are not re-checked because of it. markForCheck() marks the view and every ancestor dirty, so the whole path is refreshed.

solid answer

~40 s

Each component template runs inside a reactive consumer, so signals it reads become dependencies of that view. When one changes, Angular marks that consumer dirty and flags each ancestor as having a child view to refresh, and notifies the scheduler. On the next pass, an `OnPush` ancestor that carries only that flag is traversed without re-evaluating its bindings; the view with the dirty consumer is refreshed, and its own children are then checked in the normal way. `markForCheck()`, bound events and `ComponentRef.setInput()` instead set the dirty flag on the view and every ancestor to the root, so every `OnPush` component on that path re-runs its template. That is why signal-driven state is cheaper to update deep inside an `OnPush` tree.

code

ts · 17 lines
ts
import {Component, Injectable, inject, signal} from '@angular/core';

@Injectable({providedIn: 'root'})
export class OrderStatusStore {
  readonly pendingCount = signal(0);
}

@Component({
  selector: 'app-pending-badge',
  template: `<span class="badge">{{ store.pendingCount() }} pending</span>`,
})
export class PendingBadge {
  protected readonly store = inject(OrderStatusStore);
}

// Elsewhere: store.pendingCount.set(3);
// Next pass: PendingBadge is refreshed; its OnPush ancestors are only traversed.

go deeper

for a junior

Remember that a signal read in the template is enough to update an OnPush component when that signal changes.

for a middle

Explain that the template is a reactive consumer and that only reads during template execution are tracked, not reads in hooks.

for a senior

Contrast the targeted refresh of a signal change with the full ancestor path that markForCheck() or an event refreshes, and use it to justify signals for frequently changing state.

for a principal

Weigh signal-first state against event- and markForCheck()-driven designs for large trees, including how much Eager code on the path erodes the saving.

## Templates are signal consumers In Angular, every component template executes inside a **reactive consumer** that belongs to its view. When the template (or a getter or method it calls) reads a signal during a refresh, that read is recorded as a **dependency** of the view. Reads made elsewhere are not recorded: lifecycle hooks such as `ngOnInit()` run outside the reactive context, and a read in a constructor or a `subscribe()` callback happens outside the template's execution. When a recorded signal later changes, the view's consumer becomes **dirty**. What Angular does next is where signals differ from the older ways of marking an `OnPush` view. ## Two different marks | Mark | Set by | The view itself | Its ancestors | |---|---|---|---| | Dirty flag on the path | bound events, `markForCheck()`, `ComponentRef.setInput()` | marked dirty | marked dirty up to the root | | Input-binding mark | a template input receiving a new value | marked dirty | none needed, the parent is being refreshed | | Dirty reactive consumer | a signal read in the template changed | refreshed on the next pass | only flagged so the pass can reach it | A signal change sets a "has child views to refresh" flag on each ancestor, stopping early when an ancestor already has it, and notifies the scheduler. That flag means **walk through me**, not **refresh me**. ## Walking through one pass Take a `PendingBadge` component deep inside an `OnPush` header and layout, reading `store.pendingCount()` in its template. 1. Some code calls `pendingCount.set(3)`. The badge's consumer is marked dirty and its ancestors are flagged for traversal. 2. The next pass starts at the root. An `OnPush` ancestor that is flagged but not dirty is **not refreshed**: its template bindings are not re-evaluated. The pass only descends through it towards the flagged child. 3. The badge's view has a dirty consumer, so it **is refreshed**: its template runs and the text node updates. 4. The badge's own child components are then visited in the normal way: `Eager` children are refreshed, and `OnPush` children only if they are marked. Had the same update been done by assigning a plain field and calling `markForCheck()` in the badge, step 2 would differ: the header, the layout and every other `OnPush` ancestor on the path would have been marked dirty and refreshed too. ## Consequences - **Deep OnPush trees get cheaper updates.** A signal change refreshes the one view that displays the value instead of every template between it and the root. - **Ancestors do not see the change in their own templates.** If a parent's template also shows something derived from the same signal, the parent must read it itself, in which case its own consumer is marked as well. - **Eager ancestors may still be refreshed.** When a pass checks the whole tree, as a zone-based app does, `Eager` views along the way are refreshed anyway; the saving comes from ancestors being `OnPush`. - **Only template reads count.** A value copied from a signal in `ngOnInit()` into a plain field is a snapshot; later changes to the signal mark nothing. ## Designing for targeted refresh - **Read signals where they are displayed.** If a page reads `pendingCount()` and passes the number down as an input through three `OnPush` levels, each level's input binding marks the next one, and the change refreshes the whole chain. Letting the leaf read the signal from an injected store refreshes only the leaf. - **Keep derived values in `computed()`.** Before refreshing a view whose consumer is dirty, Angular checks whether the signals it read actually produced new values, so a `computed()` whose result did not change leads to no refresh even though its sources moved. - **Do not wrap signal writes in `markForCheck()`.** Adding it "to be safe" turns a targeted refresh back into a full ancestor-path refresh. - **Expect ancestors not to re-run.** Code that relied on a parent's template re-running whenever a child's data changed will not see signal-driven updates. ## When to prefer which - Prefer **signals read in the template** for state that changes often and is displayed deep in the tree. - Treat a **bound event** as marking the whole path anyway; that is fine for user interaction, which is rare compared with data updates. - Keep **`markForCheck()`** for integrating code that cannot expose signals, such as a callback-based third-party widget, knowing it refreshes the full ancestor path.

  • Does reading a signal in ngOnInit() make an OnPush component refresh when that signal changes?
    No. Lifecycle hooks run outside the reactive context, so a read there records no dependency; the same is true of a constructor or a `subscribe()` callback. Only reads made while the template executes, including getters or methods it calls, are tracked. Read the signal in the template instead of copying it into a field.
  • When the signal-driven view is refreshed, are its child components refreshed as well?
    They are visited in the normal way. Eager children are refreshed, and OnPush children are refreshed only if they are dirty or have their own changed signals. The targeted behaviour applies to the ancestors above the view, not to the subtree below it.

saying these in an interview costs you the question

  • A signal change re-checks the whole component tree from the root.
  • A template signal change marks every ancestor dirty, exactly like markForCheck().
  • Once a template reads signals, its changeDetection strategy no longer matters.
  • Reading a signal in ngOnInit() subscribes the view to later changes.
  • A signal's new value is written to the DOM synchronously inside set().