๐Ÿณ IntuitiveFE
Login
โ† All concepts

Angular Change Detection

โฑ๏ธ ~5-minute bite ยท solve the sandbox to master

0%lesson
๐Ÿง’

5-Year-Old Metaphor

โ€” The physical, real-world picture. No jargon.

๐Ÿšจ Default CD = fire alarm goes off, everyone evacuates the building to check if it's real. OnPush = only the floor that reported smoke gets checked. Signals = the specific smoke detector in room 304 alerts directly โ€” no building-wide evacuation.

๐Ÿ””

Default

Every component checked after every async event. Simple, correct, can be slow at scale.

๐ŸŽฏ

OnPush

Only check when @Input refs change, async pipe emits, or markForCheck() called. 90%+ reduction possible.

โšก

Signals

Fine-grained: only the template expressions that read a changed signal are re-evaluated. Zoneless future.

zone.js โ€” what it monkey-patches

setTimeoutsetIntervalclearTimeoutXMLHttpRequest.openXMLHttpRequest.sendPromise.thenPromise.catchEventTarget.addEventListenerMutationObserverIntersectionObserverrequestAnimationFrame

After any of these complete, zone.js notifies Angular: โ€œsomething async finished โ€” time to check for changes.โ€ Angular then runs a full CD cycle from the root.

๐ŸŽ›๏ธ

Interactive Sandbox

โ€” Move something, see it react instantly.

Pattern

Component tree โ€” CD blast radius

AppComponentchecked
SidebarAchecked
NavItemListchecked
ItemList โšกchecked
FooterComponentchecked
checked (CD ran) skipped targeted by signal

When to use

Small-to-medium apps, prototyping, any component that doesn't need to optimize. The default. Zone.js removes the need to think about change detection in most cases.

Default (Zone.js) โ€” code example
js
1// zone.js is bootstrapped automatically in main.ts
2// It wraps these APIs to trigger CD after each one completes:
3// setTimeout / setInterval / clearTimeout / clearInterval
4// XMLHttpRequest (open, send, abort)
5// Promise (.then, .catch, .finally)
6// EventTarget (addEventListener, removeEventListener)
7// MutationObserver / IntersectionObserver
8ย 
9// No code needed โ€” this is the default behavior
10@Component({
11 selector: 'app-counter',
12 template: `<p>{{ count }}</p><button (click)="inc()">+</button>`,
13 // changeDetection: ChangeDetectionStrategy.Default (implicit)
14})
15export class CounterComponent {
16 count = 0;
17 inc() { this.count++; }
18 // When inc() fires:
19 // 1. Click event completes
20 // 2. zone.js notifies Angular: "async work done"
21 // 3. Angular CD runs from the root component down
22 // 4. Every component in the tree is dirty-checked
23 // 5. Any binding that changed gets re-rendered
24}
25ย 
26// For large apps, this "check everything" approach becomes slow
27// Solution: ChangeDetectionStrategy.OnPush on heavy components
Gotcha: Third-party code that runs outside Angular's zone (Web Workers, some third-party animations, native browser APIs called from non-Angular code) won't trigger CD. Wrap with NgZone.run(() => ...) to re-enter the zone.
Insight: zone.js works by replacing the global setTimeout with its own version that calls the original and then notifies Angular. It's transparent to your code โ€” your setTimeout behaves identically. Angular uses zones to know 'when async work completes' without you wiring it manually.
Explored 1/5:๐Ÿ””๐ŸŽฏ๐Ÿšฉ๐Ÿ”Œโšก
๐ŸŽฏ

Challenge

Explore all 5 CD patterns. Use the tree to see the blast radius of a change in Default vs OnPush vs Signals.

Try it
๐ŸŽฏ

Why Should I Care?

โ€” The exact interview question + the bug it kills.

Interview questions

Q: Why does OnPush sometimes miss updates?

OnPush checks reference equality โ€” it only re-renders when an @Input reference changes. If you mutate an object or array that was passed as @Input (e.g., pushing to an array), the reference stays the same and Angular skips the component. The fix: always use immutable updates โ€” spread operators, Array.from, Object.assign into a new object โ€” so the parent passes a new reference.

Q: What does zone.js actually do?

zone.js monkey-patches the browser's async APIs at startup โ€” replacing setTimeout, Promise.then, XMLHttpRequest, and others with wrapper versions that call the original and then notify Angular. This is how Angular knows โ€œasync work finished, time to check for changesโ€ without you writing any notification code. The cost: any async event, even unrelated ones, triggers a full CD cycle.

Q: How do Angular signals bypass zone.js?

Signals maintain a producer/consumer graph. When you call signal.set(), the signal notifies its consumers (computed signals and template expressions that read it) directly through Angular's scheduler โ€” no zone.js involved. Angular marks only the affected template expressions as dirty and schedules a microtask to re-render them. This is how zoneless Angular (Angular 18+) works: remove zone.js entirely and rely on signals to drive all CD.

๐Ÿ”ฌ

The Deep Dive

โ€” Spec refs, engine internals, the minutiae.

ChangeDetectorRef API

markForCheck()Schedule this component and ancestors for the next CD cycle. Does not run CD immediately.
detectChanges()Run CD synchronously right now for this component and its descendants. Bypasses the scheduler.
detach()Remove from the CD tree entirely. No checks until reattach() is called.
reattach()Re-enter the CD tree. Next CD cycle will include this component.
checkNoChanges()Dev mode only: verify no bindings changed during CD (helps catch ExpressionChangedAfterItHasBeenChecked).

async pipe + OnPush โ€” the canonical pattern

The async pipe is aware of OnPush. When an Observable emits a new value through the async pipe, it automatically calls markForCheck() โ€” no manual wiring needed. This is the recommended pattern for OnPush components that consume reactive data. The async pipe also handles unsubscribe on destroy, preventing memory leaks.

js
1@Component({
2 changeDetection: ChangeDetectionStrategy.OnPush,
3 template: `
4 <div *ngIf="data$ | async as data; else loading">
5 {{ data.title }}
6 </div>
7 <ng-template #loading>Loading...</ng-template>
8 `,
9})
10export class DataComponent {
11 data$ = this.service.getData(); // Observable<Data>
12 // async pipe: subscribes, triggers markForCheck() on each emission,
13 // unsubscribes on destroy. Zero manual CD code.
14 constructor(private service: DataService) {}
15}

Zoneless Angular (Angular 18+)

provideExperimentalZonelessChangeDetection() removes zone.js from the app entirely. All CD must be driven by: signals (the primary driver), markForCheck() / detectChanges() on ChangeDetectorRef, or AsyncPipe emitting. Benefits: smaller bundle (zone.js is ~60KB), faster initial load, no monkey-patching surprises, better compatibility with native async/await. Migration path: add OnPush to all components first, replace Observable subscriptions with async pipe or toSignal(), then switch to zoneless.

๐ŸŽค

Interview Questions

โ€” Real questions from real interviews โ€” with answers.

zone.js monkey-patches async APIs so Angular knows when async work is done and should check for changes.

@Input ref change, event from within, async pipe emits, or markForCheck() is called.

markForCheck() schedules the component for the next CD cycle; detectChanges() runs CD synchronously right now.

detach() removes the component from the CD tree entirely โ€” no checks at all until reattach() is called.

Signals maintain a producer/consumer graph โ€” a signal change directly notifies only the template expressions that read it.

Apply OnPush to all components first, replace subscriptions with async pipe or toSignal(), then switch provideExperimentalZonelessChangeDetection().

๐ŸŽฎ

Memory Game

โ€” Quick quiz โ€” lock the concept in long-term memory.
1/4

What does trackBy in ngFor do for change detection?