Skip to the solution
HardFrontend10 min read

How does change detection work in Angular?

970 views

The problem

Question presented to candidate: "A teammate says Angular 're-renders everything whenever anything happens'. Walk me through how change detection actually decides what to update in a current Angular app, and what would make a value on screen go stale."

What a strong answer should cover:

  • Change detection is Angular re-running compiled template code for a view and patching only the DOM bindings whose values changed; there is no virtual DOM diff.
  • A run walks the component tree top-down, but each view is checked only if it is dirty (or is an Eager component reached by the walk).
  • What marks a view dirty: a signal read in its template changing, a template or host event, markForCheck() (also called by AsyncPipe), ComponentRef.setInput.
  • v22 defaults: OnPush is the default strategy, Eager is the opt-in old behaviour and Default is a deprecated alias; apps are zoneless by default since v21, so nothing schedules a run "because a timer fired".
  • Dev mode runs a second check pass and throws NG0100 ExpressionChangedAfterItHasBeenCheckedError if a binding changed during the first.
  • Practical consequence: state the template reads should be a signal; a plain field mutated in a setTimeout stays stale.

Clarifying questions expected:

  • "Is this a zoneless app (the v21+ default) or one that opted back into Zone.js?"
  • "Are the components on the v22 default (OnPush) or migrated with ChangeDetectionStrategy.Eager?"

Code / implementation expected: Optional β€” a small tree that shows which components re-run after a signal change, a click and a plain-field change.

  • change-detection
  • performance

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

10 min read Β· 9 sections

Read the code

AngularWhich views Angular re-checks: OnPush signal, OnPush plain field and Eager components
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ViewChild, inject, signal } from "@angular/core";

// Every template calls checked("Name"), so the console shows which views
// Angular actually re-checked after each action.
const checks: Record<string, number> = {};
function checked(name: string) { checks[name] = (checks[name] ?? 0) + 1; return ""; }
function report(action: string) {
  setTimeout(() => {
    console.log(action.padEnd(28), JSON.stringify(checks));
    for (const k in checks) delete checks[k];
  }, 50);
}

@Component({
  selector: "signal-card", // OnPush: the v22 default, nothing to write
  template: `{{ checked("Signal") }}<p>signal card: {{ count() }}</p>`,
})
export class SignalCard {
  count = signal(0);
  checked = checked;
}

@Component({
  selector: "plain-card", // OnPush too, but its state is a plain field
  template: `{{ checked("Plain") }}<p>plain card: {{ count }}</p>`,
})
export class PlainCard {
  count = 0;
  cdr = inject(ChangeDetectorRef);
  checked = checked;
}

@Component({
  selector: "eager-card",
  changeDetection: ChangeDetectionStrategy.Eager, // the old "Default", opt-in in v22
  template: `{{ checked("Eager") }}<p>eager card: {{ count }}</p>`,
})
export class EagerCard {
  count = 0;
  checked = checked;
}

@Component({
  selector: "app-root",
  imports: [SignalCard, PlainCard, EagerCard],
  template: `
    {{ checked("Root") }}
    <signal-card /><plain-card /><eager-card />
    <button id="click" (click)="clicked()">template click</button>
    <button id="signal" (click)="later('signal')">signal.set() in setTimeout</button>
    <button id="plain" (click)="later('plain')">plain field in setTimeout</button>
    <button id="mark" (click)="later('mark')">plain field + markForCheck()</button>
  `,
})
export class AppComponent {
  @ViewChild(SignalCard) signalCard!: SignalCard;
  @ViewChild(PlainCard) plainCard!: PlainCard;
  checked = checked;

  constructor() { report("first render"); }

  clicked() { report("template (click)"); }

  later(kind: "signal" | "plain" | "mark") {
    setTimeout(() => {
      // Reset after the click itself has been processed, so we only count the async part.
      for (const k in checks) delete checks[k];
      if (kind === "signal") this.signalCard.count.update(n => n + 1);
      if (kind === "plain") this.plainCard.count++;
      if (kind === "mark") { this.plainCard.count++; this.plainCard.cdr.markForCheck(); }
      report(kind === "signal" ? "signal.set (async)" : kind === "plain" ? "plain field (async)" : "markForCheck (async)");
    }, 20);
  }
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 89 of 121 in Angular

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Angular questions
Up nextHard

How does compilation work under the hood in Ivy?

Continue