Change detection is the part of Angular that keeps the DOM in sync with your component state. Interviewers love it because it separates people who have used Angular from people who understand it. Once you can explain who starts a check, which components get checked and why OnPush sometimes shows stale data, most “why didn’t my view update?” questions answer themselves. This note walks through the classic model first, then shows how signals and zoneless scheduling change the picture in modern Angular.
What a change detection pass does
Angular compiles every template into a function with a creation part and an update part. The update part re-evaluates each binding ({{ user.name }}, [disabled]="saving", and so on), compares the result with the value stored last time, and touches the DOM only when they differ. The comparison is by identity (Object.is), not deep equality.
A pass starts at the root view and walks the component tree top-down, parent before children. Data flows one way: a parent’s bindings feed its children’s inputs, so a child’s inputs are settled by the time it’s checked. Under the classic strategy every component is checked on every pass, whether or not anything relevant changed. Each check is cheap, but in a big tree it adds up.
Note
The names changed recently. The classic “check every time” strategy is now
ChangeDetectionStrategy.Eager(Defaultis a deprecated alias), and since v22 a component with nochangeDetectionsetting is OnPush. The v22 update migration setsEagerexplicitly on existing components so their behavior stays the same.
Zone.js: who started the pass
Knowing how to check isn’t enough; something has to decide when. For most of Angular’s history that was Zone.js. It monkey-patches the browser’s async APIs (setTimeout, Promise, addEventListener, XHR and more), so Angular hears about every task that runs in its zone. When the zone settles, NgZone emits and Angular calls ApplicationRef.tick(), which checks the tree from the root.
The upside feels like magic: mutate any field in any callback and the view updates. The downside is that Zone.js knows that some code ran, not what it changed. So Angular checks everything, even after a mousemove handler or a polling timer that changed nothing. The classic escape hatch was NgZone.runOutsideAngular() for noisy work, plus OnPush to make each pass cheaper.
OnPush: skipping clean subtrees
With OnPush, Angular checks a component only when its view has been marked dirty. When the traversal reaches a clean OnPush component, it skips that template and, unless something inside was flagged, its whole subtree. These are the things that mark it dirty:
- An input changes by reference, through a template binding or
ComponentRef.setInput(). - An event is handled by a template or host listener in the component or one of its descendants.
- The
asyncpipe receives a value. It callsmarkForCheck()for you. - You call
markForCheck()yourself. - A signal read in its template changes. More on this below.
Events and markForCheck() also mark every ancestor up to the root, so the path down to the component gets checked. What does not mark it is a setTimeout, a resolved promise or an HTTP response that assigns a plain field. With Zone.js a tick still runs, but the OnPush view is skipped and the DOM stays stale.
@Component({
selector: 'app-user-card',
changeDetection: ChangeDetectionStrategy.OnPush, // the default in v22+, explicit here
template: `
<h3>{{ user().name }}</h3>
<button (click)="toggle()">{{ expanded ? 'Less' : 'More' }}</button>
`,
})
export class UserCard {
readonly user = input.required<User>(); // classic: @Input({ required: true }) user!: User;
expanded = false;
toggle() {
this.expanded = !this.expanded; // fine: the click marked this view dirty
}
}Immutability and the mutation trap
Because inputs are compared by identity, mutating an object in place is invisible to an OnPush child:
@Component({
selector: 'app-profile',
imports: [UserCard],
template: `
<p>{{ user.name }}</p>
<app-user-card [user]="user" />
<button (click)="rename()">Rename</button>
`,
})
export class Profile {
user: User = { id: 1, name: 'Ada' };
rename() {
this.user.name = 'Grace'; // the <p> shows Grace, the card still shows Ada
// Fix: this.user = { ...this.user, name: 'Grace' };
}
}The click marks Profile dirty, so its own <p> updates. But [user] still holds the same reference, so the card is never marked and keeps rendering “Ada”. Assigning a new object fixes it. That’s why OnPush codebases lean on immutable updates: spread, map and filter instead of push, splice and property assignment.
Gotcha
Signals don’t protect you from mutation.
user.update(u => { u.name = 'Grace'; return u; })returns the same reference, the signal’s defaultObject.isequality says nothing changed, and nothing downstream updates. Return a new object:user.update(u => ({ ...u, name: 'Grace' })).
ChangeDetectorRef methods
Inject it with inject(ChangeDetectorRef) when you need manual control:
| Method | What it does | Typical use |
|---|---|---|
markForCheck() |
Marks this view and its ancestors dirty for the next pass | A callback assigned a plain field in an OnPush component |
detectChanges() |
Checks this view and its children synchronously, right now | Local checks together with detach(); tests |
detach() |
Removes the view from the tree; it’s skipped until reattached | Widgets that refresh on their own schedule |
reattach() |
Puts a detached view back into the tree | Resume normal checking |
markForCheck() doesn’t run anything, it only schedules. detectChanges() runs immediately, and it’s often used to paper over the error described at the end of this note; treat that as a smell. In modern code you rarely need either, because the async pipe, toSignal() and plain signals do the marking for you.
Signals and zoneless change detection
@Component({
selector: 'app-cart-summary',
template: `<p>{{ count() }} items, total {{ total() }}</p>`,
})
export class CartSummary {
readonly items = signal<CartItem[]>([]);
readonly count = computed(() => this.items().length);
readonly total = computed(() => this.items().reduce((sum, i) => sum + i.price, 0));
constructor() {
effect(() => localStorage.setItem('cart', JSON.stringify(this.items())));
}
add(item: CartItem) {
this.items.update((list) => [...list, item]);
}
}signal()holds a value.set()andupdate()notify whatever read it.computed()is lazy and memoized. It recalculates only after a signal it read changes, and it tracks dependencies dynamically.effect()runs at least once, then again when anything it read changes. Effects run asynchronously during change detection and need an injection context. Use them for side effects (storage, logging, third-party widgets), not for copying one signal into another; derive that withcomputed().
The template itself is a reactive consumer. When items changes, Angular knows exactly which views read it. It marks those views for refresh and flags their ancestors only so the traversal can reach them. Unlike markForCheck(), the ancestors’ templates are not re-evaluated. The granularity is the component view, not individual DOM nodes, but that’s far tighter than “check everything”.
That precision is what makes Zone.js unnecessary. A zoneless app schedules a pass only when Angular is notified: a signal read in a template changes, a template or host listener runs, markForCheck() is called (the async pipe does this), or ComponentRef.setInput() sets an input. According to angular.dev, zoneless is the default from v21, and v20 opts in with provideZonelessChangeDetection(). The flip side is that a plain field assigned in setTimeout or a subscribe callback doesn’t render until something else triggers a pass, even in an Eager component, because nothing told Angular. NgZone.onStable and onMicrotaskEmpty never emit either; use afterNextRender() instead.
Interview tip
A crisp line for interviews: “Zone.js tells Angular that something happened; signals tell it what changed and who cares.” Follow up with the contrast:
markForCheck()dirties the whole ancestor path, while a signal change refreshes only the views that read it.
ExpressionChangedAfterItHasBeenChecked
In development mode, Angular follows each pass with a second, verification pass. If a binding now produces a different value, it throws NG0100, because the screen would disagree with the state it just rendered. The classic trigger is changing a bound field after the view was checked:
@Component({
selector: 'app-banner',
changeDetection: ChangeDetectionStrategy.Eager,
template: `<p>{{ message }}</p>`,
})
export class Banner implements AfterViewInit {
message = 'Loading';
ngAfterViewInit() {
this.message = 'Loaded'; // NG0100: Previous value: 'Loading'. Current value: 'Loaded'.
}
}Other common causes are a child writing to its parent’s state during the parent’s check, and template methods or getters that return a different primitive on every call, such as Date.now() or Math.random(). To fix it, set initial values in the constructor or ngOnInit, derive values with computed() instead of pushing them around in lifecycle hooks, and keep template expressions pure. Wrapping the assignment in setTimeout hides the symptom without fixing the data flow.
Gotcha
Delete the
Eagerline and the error disappears, but so does the update. The OnPush view was already checked and nothing marks it dirty again, so the page silently keeps showing “Loading”. Makemessagea signal and the new value renders without any error.
The interview answer
“Change detection is Angular re-evaluating template bindings and patching the DOM where values changed, walking the component tree top-down. Historically Zone.js patched async APIs and triggered a full pass after every task, and the eager strategy checked every component every time. OnPush narrows that: a component is checked only when it’s marked dirty, which happens when an input changes by reference, an event fires in its subtree, the async pipe emits, markForCheck() is called, or a signal it reads changes. That’s why OnPush code updates data immutably.
Signals go further. Angular knows which views read which signals, so it refreshes only those views, and it knows when to schedule a pass without Zone.js. That’s zoneless change detection, now the default. The gotchas I watch for are mutated inputs, plain fields assigned in async callbacks, and NG0100, which is dev mode telling me a binding changed after it was checked.”