Change Detection — Default, OnPush, Signals, and Zoneless

Change detection is the mechanism Angular uses to keep the DOM in sync with component state. Every time something asynchronous happens — an HTTP response, a user click, a timer firing — Angular decides which parts of the UI might have changed and updates them. Understanding how change detection works, why it can become a performance bottleneck in large applications, and how to control it with OnPush, signals, and Angular’s emerging zoneless mode separates engineers who can reason about Angular performance from those who guess and pray.

Change Detection Strategies

Strategy Checks When Use For
Default Any async event anywhere in the app — zone.js triggers a full tree check Prototyping, components with mutable inputs
OnPush Only when an @Input() reference changes, an async pipe emits, or markForCheck() is called Presentational components, performance-critical subtrees
Signals (implicit) Only when a signal read in the template changes — fine-grained, no zone needed Modern Angular — components using signals for all state
Zoneless (experimental) Driven entirely by signals and explicit marks — no zone.js overhead Maximum performance, server-side rendering

OnPush Triggers

What Triggers OnPush Check What Does NOT Trigger
New @Input() object/array reference (=== changes) Mutating an input object in place (obj.name = 'new')
Observable emitting via async pipe Observable emitting without async pipe
Signal value changing (read in template) Promise resolving without zone notification
cdr.markForCheck() called explicitly DOM event on a child with Default strategy
DOM event fired on this component or its children Internal mutable state changes without signals
Note: Angular 17+ applications using signals throughout their component state effectively get granular, reactive change detection without needing OnPush — the signals graph tracks exactly which template bindings depend on which signals, and only those bindings are updated. The combination of signals for state + OnPush on presentational components is the recommended architecture for new Angular applications, giving both the expressiveness of reactive state and maximum rendering performance.
Tip: Apply ChangeDetectionStrategy.OnPush to all presentational (leaf) components from day one. The overhead is minimal and the performance gains compound — a task list with 100 TaskCardComponent items using OnPush and immutable inputs only re-checks the cards whose inputs changed, instead of re-checking all 100 on every mouse move. Use trackBy with ngFor alongside OnPush for the full list optimisation.
Warning: Never mutate objects or arrays passed as @Input() to an OnPush component. If the parent does task.title = 'New Title' instead of task = { ...task, title: 'New Title' }, the reference is the same — Angular does not re-check the OnPush component and the UI does not update. Always produce new object references: spread operators, Array.map(), Array.filter(), signal.update().

Complete Change Detection Examples

import {
    Component, Input, ChangeDetectionStrategy, ChangeDetectorRef,
    signal, computed, inject, OnInit,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

// ── OnPush presentational component ──────────────────────────────────────
@Component({
    selector:    'app-task-card',
    standalone:  true,
    imports:     [CommonModule],
    changeDetection: ChangeDetectionStrategy.OnPush,
    template: `
        <article class="task-card" [class.task-card--overdue]="isOverdue">
            <h3>{{ task.title }}</h3>
            <span class="badge">{{ task.priority }}</span>
        </article>
    `,
})
export class TaskCardComponent {
    @Input({ required: true }) task!: Task;

    get isOverdue(): boolean {
        return !!this.task.dueDate
            && new Date(this.task.dueDate) < new Date()
            && this.task.status !== 'completed';
    }
    // Only re-renders when task reference changes
}

// ── OnPush + signals — full modern pattern ────────────────────────────────
@Component({
    selector:    'app-task-list',
    standalone:  true,
    imports:     [CommonModule, TaskCardComponent],
    changeDetection: ChangeDetectionStrategy.OnPush,
    template: `
        <ul>
            @for (task of filteredTasks(); track task._id) {
                <app-task-card [task]="task"></app-task-card>
            }
        </ul>
    `,
})
export class TaskListComponent implements OnInit {
    private taskService = inject(TaskService);

    tasks        = signal<Task[]>([]);
    filterStatus = signal('');
    filteredTasks = computed(() => {
        const s = this.filterStatus();
        return s ? this.tasks().filter(t => t.status === s) : this.tasks();
    });

    ngOnInit(): void {
        this.taskService.getAll()
            .pipe(takeUntilDestroyed())
            .subscribe(tasks => this.tasks.set(tasks));
        // Signal update automatically schedules a view check — no markForCheck needed
    }
}

// ── Manual markForCheck for non-zone async ────────────────────────────────
@Component({
    changeDetection: ChangeDetectionStrategy.OnPush,
    template: `<p>{{ time }}</p>`,
})
export class ClockComponent implements OnInit {
    time = '';
    private cdr = inject(ChangeDetectorRef);

    ngOnInit(): void {
        setInterval(() => {
            this.time = new Date().toLocaleTimeString();
            this.cdr.markForCheck();  // notify Angular to check this component
        }, 1000);
    }
}

// ── Zoneless Angular (experimental, Angular 18+) ──────────────────────────
// app.config.ts
import { provideExperimentalZonelessChangeDetection } from '@angular/core';

export const appConfig = {
    providers: [
        provideExperimentalZonelessChangeDetection(),
        provideRouter(routes),
        // All state changes must go through signals, async pipe, or explicit marks
    ],
};
// angular.json — also remove zone.js: "polyfills": []

How It Works

Step 1 — Zone.js Monkey-Patches Async APIs

Zone.js intercepts browser async APIs: setTimeout, Promise.then(), addEventListener(), XMLHttpRequest. When any of these complete, zone.js notifies Angular, which triggers a change detection cycle walking the entire component tree. With Default strategy, this happens for every async event — a mouse move can trigger hundreds of checks per second across thousands of components.

Step 2 — OnPush Creates a Subtree That Opts Out of Default Checking

An OnPush component and all its descendants are skipped during the default change detection walk unless one of the OnPush triggers fires. Angular marks the component “dirty” only when an input reference changes, an async pipe emits, a signal changes, or markForCheck() is called. This can reduce the number of components checked on each cycle from thousands to dozens.

Step 3 — Signals Provide Fine-Grained Reactivity

When a component template reads a signal with {{ count() }}, Angular registers that template as a consumer of the signal. When the signal changes, Angular marks only that component’s view as dirty — not the entire subtree. Combined with OnPush, this provides both coarse-grained (OnPush) and fine-grained (signals) control.

Step 4 — markForCheck() vs detectChanges()

cdr.markForCheck() marks the component and all its ancestors as dirty — the next cycle will check this component. cdr.detectChanges() immediately runs change detection for this component and its children synchronously. Use markForCheck() for most cases — it batches with other updates. Use detectChanges() only when you need immediate synchronous DOM update.

Step 5 — Zoneless Mode Requires Explicit Change Notification

Without zone.js, Angular has no automatic way to know something asynchronous happened. Every state change must go through signals, the async pipe, or explicit markForCheck() calls. With signals covering all component state, the vast majority of change detection works automatically — and the reward is a ~100KB smaller bundle and faster initial load.

Common Mistakes

Mistake 1 — Mutating inputs in OnPush components without new reference

❌ Wrong — object mutation does not trigger OnPush re-render:

updateTask(): void {
    this.task.title = 'New Title';   // same reference — OnPush child won't update!
}

✅ Correct — always produce a new reference:

updateTask(): void {
    this.task = { ...this.task, title: 'New Title' };  // new reference — re-renders
}

Mistake 2 — Using setInterval without markForCheck in OnPush component

❌ Wrong — interval fires but UI never updates:

ngOnInit(): void {
    setInterval(() => { this.elapsed++; }, 1000);  // zone not notified — no check
}

✅ Correct — use a signal:

elapsed = signal(0);
ngOnInit(): void {
    setInterval(() => this.elapsed.update(n => n + 1), 1000);
}

Mistake 3 — Using OnPush with mutable service BehaviorSubject state

❌ Wrong — subscribe without async pipe or markForCheck:

ngOnInit(): void {
    this.taskService.tasks$.subscribe(t => {
        this.tasks = t;  // OnPush doesn't know — UI stale!
    });
}

✅ Correct — use async pipe or signal:

// In template: *ngFor="let task of tasks$ | async"
// OR: tasks = toSignal(this.taskService.tasks$, { initialValue: [] });

Quick Reference

Task Code / Pattern
Apply OnPush changeDetection: ChangeDetectionStrategy.OnPush
Mark dirty manually inject(ChangeDetectorRef).markForCheck()
Immediate sync update inject(ChangeDetectorRef).detectChanges()
Signal triggers OnPush Read signal in template — change auto-schedules check
Async pipe triggers OnPush observable$ | async in template
New reference for OnPush this.task = { ...this.task, field: value }
Zoneless mode provideExperimentalZonelessChangeDetection()

🧠 Test Yourself

An OnPush component receives a tasks: Task[] input. The parent adds a task by calling this.tasks.push(newTask). The child does not update. Why?