Change Detection Fundamentals
Change detection is the process Angular uses to keep the DOM in sync with your component state. Whenever a value your template depends on changes, Angular needs to figure out what changed and which parts of the page to update. Understanding how this cycle works — when it fires, how it walks the component tree, and what triggers it — is the single most important skill for diagnosing why a view does or does not update, and for tuning performance in large applications.
What change detection actually does
A component template is a description of how state maps to the DOM. The expression {{ user.name }} says “render the current value of user.name here.” But Angular cannot magically know when user.name changes — JavaScript objects do not emit events when their properties are reassigned. Change detection bridges that gap: Angular periodically re-evaluates every template expression, compares the new result to the previously rendered value, and patches the DOM only where the two differ.
Crucially, change detection never mutates your component state. It is a read-only pass over the component tree that reflects state into the DOM. If a binding’s value is unchanged, Angular skips the corresponding DOM operation entirely.
What triggers a change detection cycle
Angular does not run change detection continuously. It runs it in response to events that could have changed application state. By default, the zone.js library patches the browser’s async APIs and notifies Angular whenever one of them completes. The common triggers are:
| Trigger | Examples |
|---|---|
| DOM events | click, input, submit, keydown |
| Timers | setTimeout, setInterval |
| Async I/O | XMLHttpRequest, fetch, Promise resolution |
| Manual calls | ApplicationRef.tick(), ChangeDetectorRef.detectChanges() |
| Signals | A signal read in a template being updated |
When any of these fire, Angular schedules a tick. A tick is one full change detection cycle over the entire component tree (or, with OnPush and signals, a targeted subset of it).
Reassigning a value inside
setTimeoutworks becausezone.jspatchedsetTimeout. If you run code outside the Angular zone (viaNgZone.runOutsideAngular), updates will not be reflected until the next tick.
How the tree is traversed
Angular components form a tree rooted at the bootstrapped component. During a tick, Angular walks this tree top-down, depth-first, in a single synchronous pass. For each component it:
- Updates input bindings received from the parent.
- Runs
ngOnChanges(if inputs changed), thenngDoCheck. - Evaluates the component’s template bindings and updates the DOM where values differ.
- Runs the after-render lifecycle hooks for that view.
- Recurses into child components.
Because the pass is top-down, a parent is always checked before its children. This ordering is what makes the unidirectional data flow rule enforceable: once a parent has been checked, its child’s inputs are considered stable for the rest of that tick.
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<p>Count: {{ count() }}</p>
<button (click)="increment()">Increment</button>
`,
})
export class CounterComponent {
count = signal(0);
increment() {
this.count.update((n) => n + 1);
console.log('clicked, new value:', this.count());
}
}
Output:
clicked, new value: 1
clicked, new value: 2
The click triggers a tick; Angular re-runs the template, sees count() returned a new value, and patches the <p> text node — but leaves the <button> untouched.
Detecting changes: identity comparison
For each binding, Angular compares the new value against the last one using a strict reference check (===, via the internal Object.is). This has an important consequence: mutating an object in place does not change its reference, so the comparison may report “no change” even though the contents differed.
// Mutation — same reference, may be missed by OnPush components
this.user.name = 'Ada';
// Replacement — new reference, always detected
this.user = { ...this.user, name: 'Ada' };
With the default strategy this usually still works because Angular re-reads every binding each tick, but with OnPush (and with signals) immutable updates are required for reliable detection.
The dev-mode double check
In development builds, Angular runs change detection twice per tick. After the first pass it re-checks every binding; if any value changed between the two passes, it throws ExpressionChangedAfterItHasBeenCheckedError. This guards against templates whose values depend on side effects produced during rendering — a violation of unidirectional flow. Production builds skip the second pass.
ERROR Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError:
Expression has changed after it was checked.
Previous value: 'false'. Current value: 'true'.
Moving toward fewer checks
The default strategy checks the whole tree on every trigger, which is simple but can be wasteful. Angular provides two escalating optimizations: the OnPush strategy prunes branches that have not received new inputs or fired local events, and a fully zoneless mode replaces zone.js entirely with signal-driven scheduling, so only components whose signals changed are dirtied.
Best practices
- Prefer immutable updates (
{ ...obj }, new arrays) so reference comparisons reliably detect changes. - Adopt signals for new state — they mark exactly the components that read them as dirty, avoiding full-tree checks.
- Use
OnPushon presentational components to cut the number of views checked per tick. - Keep template expressions pure and cheap; they run on every check, so avoid method calls that allocate or compute heavily.
- Run expensive, non-UI async work with
NgZone.runOutsideAngularto avoid triggering needless ticks. - Never change bound state inside lifecycle hooks that run during rendering, or you will hit
ExpressionChangedAfterItHasBeenCheckedError.