skip to content

In Angular, what marks an OnPush component's view dirty so that the next change detection pass actually re-checks it?

level: middleimportance: must knowfreq 72%

answer

  1. four kinds of notification
  2. input identity, not contents
  3. handlers wired through the template
  4. manual mark and template signals

basics

~20 s

A template-bound input receiving a new reference, an Angular-bound event firing in the component or a descendant, a markForCheck() call, or a change to a signal its template reads. Mutations and plain field writes mark nothing.

solid answer

~40 s

In practice, four things. First, a parent template binding passes an input a value that is not `Object.is`-equal to the previous one (`ComponentRef.setInput()` does the same for dynamic components). Second, an event handled through Angular (a `(click)` in the template, an `output()` listener, a host listener) fires in the component or any descendant; the listener wrapper marks that view and every ancestor dirty before the handler runs. Third, `ChangeDetectorRef.markForCheck()` is called, which the `async` pipe does for you. Fourth, a signal read in the template changes. What does not mark it: mutating a bound object, assigning a field in a timer or `subscribe()` callback, writing an input through a `@ViewChild` reference, or a listener added with `addEventListener`.

code

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

export interface Order {
  id: number;
  item: string;
  qty: number;
}

@Component({
  selector: 'app-order-row',
  template: `
    <span>{{ order().item }} x {{ order().qty }}</span>
    <button (click)="toggle()">Details</button>
    @if (expanded()) {
      <span>Order #{{ order().id }}</span>
    }
  `,
})
export class OrderRow {
  // Marked when the parent binds a different Order reference.
  readonly order = input.required<Order>();
  // Marked when this signal, read in the template, changes.
  readonly expanded = signal(false);

  // The (click) binding marks this view and its ancestors before toggle() runs.
  toggle() {
    this.expanded.update((open) => !open);
  }
}

go deeper

for a junior

Remember the four triggers: new input reference, bound event, markForCheck(), and a changed signal in the template.

for a middle

Explain the identity comparison on input bindings, that bound listeners mark the view and all ancestors before running, and which look-alike paths mark nothing.

for a senior

Use the reach of each mark to diagnose stale views and to choose signals over events or markForCheck() where refreshing a whole ancestor path is costly.

for a principal

Treat the trigger list as the component contract a codebase must honour, and decide which conventions (signals for state, immutable updates) the team enforces in review.

## Dirty marking is the whole contract An `OnPush` component (the default since Angular 22) is refreshed by a change detection pass only if its view carries a mark saying it needs one. Knowing exactly what sets that mark is what makes `OnPush` predictable: every template value must change through one of the paths below, or the DOM will not follow. ## The four triggers 1. **A template-bound input receives a new value.** While the parent's view is refreshed, each input binding is compared with its previous value using `Object.is`. If it differs, the input is written and, when the target is an `OnPush` component, its view is marked dirty. The same array or object reference counts as unchanged, even if its contents were mutated. `ComponentRef.setInput()` does the same job for dynamically created components: it skips an identical value, otherwise writes the input and marks the component's view. 2. **An event handled through Angular fires in the component or a descendant.** Angular wraps every listener it registers: a `(click)` in a template, a listener on a child's `output()`, and host listeners declared with `host: {'(keydown)': ...}` or `@HostListener`. Before the handler runs, the wrapper marks the listener's view and **every ancestor up to the root** dirty. It does this whether or not the handler changes anything. 3. **`ChangeDetectorRef.markForCheck()` is called.** It marks the view and every ancestor dirty. The `async` pipe calls it for you when a new value arrives. 4. **A signal read in the template changes.** Each template acts as a reactive consumer; when a signal it read changes, that view is flagged for refresh on the next pass. ## Things that look like triggers but are not - **Mutating a bound object or array** (`orders.push(o)`, `order.status = 'shipped'`): the reference is the same, so the binding reports no change. - **Assigning a plain field** in a `setTimeout`, promise, `subscribe()` or third-party callback: nothing marks the view. - **Setting an input through a `@ViewChild` or `@ContentChild` reference**: a direct property write bypasses the binding, so nothing is marked. - **A DOM listener added with `addEventListener`** instead of a template or host binding: Angular did not wrap it, so it marks nothing. - **A signal read in `ngOnInit()` or the constructor**: lifecycle hooks run outside the template's reactive context, so no dependency is recorded. ## How far each mark reaches | Trigger | View it marks | Ancestors | |---|---|---| | Template input binding (new identity) | the child component's view | already being refreshed as the parent is checked | | `ComponentRef.setInput()` | the component's view | marked dirty up to the root | | Angular-bound event | the view that owns the listener | marked dirty up to the root | | `markForCheck()` | the view it was called for | marked dirty up to the root | | Template signal read changed | that view is refreshed | only marked so the pass can reach it | The difference in the last row matters in deep trees: a signal change refreshes one view, while an event or `markForCheck()` refreshes every `OnPush` component on the path from the root. ## Diagnosing a stale OnPush view When an `OnPush` component shows an old value, walk the triggers in order: 1. **Where does the value come from?** An input, a field, a signal or a service. 2. **If it is an input**, did the owner produce a new reference, or mutate the old one? 3. **If it is a field**, is it assigned inside an Angular-bound handler, or in a callback Angular never wrapped? 4. **If it is a signal**, is it read in the template, or copied into a field in `ngOnInit()`? 5. **If it comes from a service**, does the service expose a signal, or a mutable property the template reads? The first answer that falls outside the four triggers is the bug. Adding `markForCheck()` at that point makes the symptom disappear, but routing the value through a signal or an immutable update removes the cause. ## Marking is not scheduling Marking says **what** the next pass should refresh; something else decides **when** a pass runs. In a zone-based app zone.js triggers passes after async work; in a zoneless app (the default since v21) the marking paths above also notify the scheduler. A plain field assignment does neither, which is why the fix for a stale `OnPush` view is always to route the change through one of the four triggers, most often by holding the state in a signal or replacing the object instead of mutating it.

  • Does ComponentRef.setInput() behave like a template binding for an OnPush component?
    Yes. `setInput()` ignores a value that is `Object.is`-equal to the last one it set; otherwise it writes the input and marks the component's view and its ancestors dirty, which also notifies the scheduler. Assigning the same property directly on the instance does neither, so `setInput()` is the supported way to feed inputs to dynamically created components.
  • Why does an event in an OnPush child also re-check its OnPush parent?
    The listener wrapper marks the view that owns the listener and walks up, marking every ancestor to the root dirty. The next pass therefore refreshes the whole path from the root down to the child. OnPush subtrees off that path stay skipped unless something marked them too.

saying these in an interview costs you the question

  • Pushing into an array bound to an OnPush input marks the child dirty.
  • Any DOM event anywhere in the app re-checks every OnPush component.
  • A field assigned in a setTimeout callback updates an OnPush template by itself.
  • OnPush compares inputs property by property, like a deep equality check.
  • Setting an input through a @ViewChild reference marks the child view dirty.