193. Module Introduction

The text introduces a section focused on Angular’s Change Detection mechanism—an internal feature that runs automatically in the background but can be optimized by developers.

It will cover:

  • What change detection is and how it works internally

  • Performance optimization options, especially the OnPush strategy

  • How change detection behavior differs when using signals

  • The relationship to ZoneJS, including cases where ZoneJS might be avoided

The section is described as theory-heavy and will use a prepared dummy app (not a full real-world project) to demonstrate and explain the different change detection mechanisms Angular developers should understand.

194. Analyzing the Starting Project

A dummy Angular project is introduced to explore how change detection works. After running npm install and npm start, the app shows multiple interacting components: a counter with buttons, a message input that adds entries to a list, and a dummy log output area. In the browser dev tools, many console logs appear—added via component “getter” properties—to reveal when Angular evaluates components and templates during change detection. The project is used to observe the high amount of change-detection activity and to learn how to control and optimize it.

195. Understanding How Angular Performs Change Detection

Angular’s default change detection wraps the whole app in a Zone.js “zone” that notifies Angular when events happen (e.g., a button click). When an event occurs, Angular runs change detection for the entire component tree, revisiting every component’s template and reevaluating all template bindings (property bindings, interpolations, etc.). If any binding value changed compared to what was previously rendered, Angular updates the real DOM.

This is why a single counter increment can produce log messages from many unrelated components: all components get checked, and any getter used in template bindings (like a debugOutput getter used via interpolation) is executed every time those bindings are reevaluated. Because of this, expensive work in getters or template expressions should be avoided. Angular is optimized to perform these checks efficiently, and further optimizations/alternative strategies can reduce unnecessary checks.

The passage ends by noting that logs show components being checked twice per event and raises the question of why Angular runs two checks.

196. Change Detection During Development -ExpressionChangedAfterChecked Errors

Angular shows duplicate log output in development mode because it deliberately runs change detection twice. This helps Angular detect unintended state/value changes that occur after a change detection pass.

  • In production builds, this double-run does not happen.

  • The goal is to catch unstable values or side effects. Example: if a component method used in the template (like debugOutput) returns a random value, the first and second change detection passes will produce different results.

  • When Angular detects that a value changed between these two immediate checks, it throws ExpressionChangedAfterItHasBeenCheckedError, indicating a likely bug where displayed data is being changed unexpectedly.

Using a stable value (e.g., a fixed string) removes the error because the output stays the same across both checks.

197. Writing Efficient Template Bindings

  • Angular change detection runs frequently, so template code should stay lightweight.

  • Avoid expensive computations in templates; keep binding expressions simple and straightforward.

  • Don’t call arbitrary functions in template interpolations/bindings (event bindings and signal reads are exceptions).

  • If you use getters (which are effectively methods), ensure they do only basic, efficient work.

  • Pipes are executed during template evaluation too, which is why Angular caches pipe transform results by default; keep this behavior in mind, and note there are additional optimizations beyond this.

198. Avoiding Zone Pollution

The text explains an Angular performance optimization: prevent unnecessary change detection triggered by browser events (like setTimeout) that don’t affect the UI.

  • By default, Angular runs inside zone.js, which listens to async events (including expired timers) and triggers change detection whenever they occur—whether or not your code actually updates component state.

  • Example: one timer resets a counter (should trigger change detection), but a second timer only logs "Timer expired" (doesn’t affect UI). Even the logging timer still causes change detection because zone.js can’t know it’s “harmless.”

  • Optimization: inject NgZone (via inject(NgZone) or constructor injection) and wrap non-UI-affecting code in zone.runOutsideAngular(() => { …​ }).
    This executes that code outside Angular’s zone so the event won’t trigger change detection.

  • This technique is occasionally useful to avoid extra change detection cycles and is sometimes described as avoiding “zone pollution.”

199. Using the OnPush Strategy

Angular’s default change detection checks the whole app on every event, which can be costly. A stronger performance technique is opting into the OnPush change detection strategy on specific components so change detection runs less often for them.

The example enables OnPush in MessagesComponent by setting:

  • changeDetection: ChangeDetectionStrategy.OnPush (imported from @angular/core)

When OnPush is enabled on MessagesComponent, change detection no longer runs for MessagesComponent and its child components (NewMessageComponent, MessagesListComponent) during unrelated events like the counter reset or increment/decrement—confirmed by the absence of their console logs. Switching back to the default strategy brings those logs back, showing that default change detection re-checks them on those events. The section ends by asking why OnPush causes this behavior and how it works.

200. Understanding the OnPush Strategy

Angular’s OnPush change detection tells Angular to re-check a component (and its children) only when one of these happens:

  1. An event occurs in that component or any of its child components (e.g., clicks, keypresses).

  2. An @Input reference changes on that component (if it has inputs).

  3. Change detection is triggered manually (e.g., via APIs like markForCheck / detectChanges).

Because it narrows what can trigger checks, OnPush can significantly improve performance, especially in larger apps where many components don’t need to re-render for unrelated changes.

Key behaviors illustrated:

  • Setting OnPush on MessagesComponent prevents it (and its subtree) from being checked when interacting with a sibling like CounterComponent, since sibling events shouldn’t affect it.

  • Setting OnPush on NewMessageComponent doesn’t stop its own events from triggering checks: typing with two-way binding fires events on every keystroke, which can still cause change detection to propagate upward and potentially lead to broader checks.

  • OnPush doesn’t prevent events from bubbling up to parent components; it mainly prevents unnecessary re-evaluation of the OnPush component unless one of the allowed triggers occurs.

  • To stop unrelated parts (like CounterComponent) from being checked due to events elsewhere (like typing in a message box), you should apply OnPush to the components you want to protect (e.g., CounterComponent, then even InfoMessageComponent inside it).

  • For components with inputs (e.g., MessagesListComponent), enabling OnPush means it won’t re-check during unrelated events (like typing), but it will re-check when its input changes (e.g., when a new message is saved and the messages array/reference updates).

Overall: OnPush is a practical optimization when your UI isn’t so tightly coupled that “everything changes all the time.”

201. Working with OnPush & Signals

  • OnPush change detection has an additional trigger beyond the commonly taught ones: Angular Signal changes can cause an OnPush component (and potentially its child components) to be checked again.

  • Signals are a newer Angular feature introduced in Angular 17, so this only applies if your project uses Signals; otherwise, OnPush behaves as previously described.

  • Often this extra trigger doesn’t noticeably change behavior because Signals are frequently updated in event handlers, and events already trigger OnPush checks anyway.

  • Where it can matter is when using a shared service to distribute data across multiple OnPush components: without Signals, updates might not propagate as expected, leading to surprising behavior. The next section will demonstrate that scenario.

202. Using Signals for Sharing Data Across Components (with OnPush)

  • A new messages.service.ts is introduced (placed next to messages.component) to centralize message state. It uses a signal to store an array of strings, exposes a read-only signal for consumers, and provides an addMessage() method to update the array.

  • new-message.component is refactored to inject MessagesService (using Angular’s inject()), remove the @Output, and call messagesService.addMessage(enteredText) on submit instead of emitting an event.

  • messages.component is simplified: it no longer holds a local messages signal, no longer has an onAddMessage() handler, and removes now-unneeded signal-related imports and template bindings.

  • messages-list is also refactored: it removes the @Input and instead injects MessagesService. It defines a public messages property pointing to MessagesService.allMessages (keeping it as a signal reference, not calling it), so the template can read it like before.

  • All involved components (messages-list, new-message, and messages-component) use OnPush change detection. With the signal-based service, everything continues to work correctly (messages appear and logs behave as before).

  • The setup is used to motivate the next step: migrating the service from signals to non-signal state, which will reveal an OnPush-related update problem.

203. The Problem With OnPush, Cross-Component Data & Not Using Signals

  • Migrated an Angular MessagesService from using Signals to using a plain array property (string[]), initialized empty.

  • Updated:

    • allMessages() to return a copy of the array.

    • addMessage() to create a new copied array with the new message appended (immutably replacing the property).

  • In NewMessageComponent, kept calling addMessage() the same way; optionally changed entered text from a Signal to a normal string and reset it by assignment. Template bindings stay the same.

  • In MessagesListComponent, replaced Signal-based access with a getter that returns messagesService.allMessages(), and updated the template to use the array directly (no Signal “read” call).

  • Result: adding messages still works in the service, but the list UI doesn’t update.

  • Cause: MessagesListComponent uses OnPush change detection, and it has:

    • no changing @Input(),

    • no local events triggering checks,

    • no Signals changing inside the component, so change detection never runs for it, and the new message isn’t rendered.

  • Next step indicated: explore ways to handle this scenario (manual triggering, different patterns, etc.) in subsequent lectures.

204. Triggering Change Detection Manually & Using RxJS Subjects

  • To make MessagesListComponent update again (especially with OnPush), change detection must be triggered manually using ChangeDetectorRef (e.g., markForCheck()), but it must be called inside the component, not inside the service.

  • Since the service is where the data changes, the component needs a notification mechanism to learn about those changes. This is done with RxJS, using a BehaviorSubject to wrap the messages array and emit updates.

  • In the service:

    • Create messages$ (naming convention: $ for RxJS streams) as new BehaviorSubject<string[]>(initialValue).

    • After updating the internal messages array in addMessage, call messages$.next(updatedMessages) to emit the new array (ideally a copy to prevent accidental mutation by subscribers).

  • In MessagesListComponent:

    • Subscribe to messagesService.messages$ in ngOnInit.

    • In the subscription callback:

      • Assign the received messages to a local messages: string[] property (instead of using a getter).

      • Call cdRef.markForCheck() to tell Angular to re-check this component.

  • RxJS subscription alone doesn’t fix the UI update; markForCheck() is the key that re-triggers change detection for the component.

  • Best practice: clean up the subscription when the component is destroyed:

    • Inject DestroyRef, register an onDestroy handler, and call subscription.unsubscribe() there (or use ngOnDestroy). This prevents memory leaks if the component is ever removed.

205. Introducing The async Pipe

Angular OnPush components can use an RxJS BehaviorSubject from a service to update the UI, but manually subscribing, storing values in a component property, calling ChangeDetectorRef to trigger change detection, and cleaning up subscriptions creates a lot of boilerplate.

A built-in shortcut is to expose the observable/subject directly on the component (e.g., messages$) and bind to it in the template with Angular’s async pipe:

  • async automatically subscribes to the observable/subject

  • it provides the latest emitted value to the template (so *ngFor still works)

  • it automatically unsubscribes when the component is destroyed

  • it triggers change detection when new values arrive (works well with OnPush)

To use it, import the AsyncPipe from @angular/common (or enable it via the component’s imports in standalone components).

This approach is more elegant for simple “display observable value” scenarios, but if you need more complex logic than just rendering the emitted values, you may still need manual subscriptions and change detection management.

207. Going Zoneless!

Angular’s change detection traditionally relies on zone.js, which wraps the app and notifies Angular when async events happen (timers, promises, etc.), triggering change detection.

With Angular Signals (and Angular’s own event bindings), Angular can detect changes without zone.js because:

  • Signals are created via Angular APIs, so updating a signal automatically notifies Angular.

  • Event bindings (e.g., (click)) are handled by Angular, so Angular can trigger checks on those events even without zone.js.

  • When signals are read in templates, Angular tracks dependencies and can update only the affected parts.

As a result, in Angular (notably v18), you can go zoneless, gaining:

  • More fine-grained change detection

  • Smaller bundle size (zone.js removed)

  • Fewer behind-the-scenes listeners/checks

The text then walks through converting an app from RxJS/subjects back to signals:

  • In a messages service: replace Subject/RxJS with a signal<string[]>, expose asReadonly(), and update via messages.update(…​).

  • In components: use signals for input state (enteredText), remove AsyncPipe, and read signals directly in templates.

To remove zone.js (two steps):

  1. Remove zone.js from angular.json under build -> polyfills (restart dev server).

  2. In main.ts, add provideExperimentalZonelessChangeDetection() to bootstrapApplication providers.

After disabling zone.js:

  • Signal updates still trigger change detection; parent components may still be checked (“bubbling up”).

  • Click-driven updates still work even without signals because Angular knows about DOM events.

  • Timers no longer trigger UI updates unless they update a signal (since zone.js is what used to notify Angular about expired timers).

Conclusion: in a zoneless Angular app, to ensure async work like setTimeout updates the UI, you should manage changing state with signals, so Angular is notified whenever values change.