Now that we understand the “why” behind zoneless change detection, let’s dive into the “how.” In this chapter, we’ll walk through what it means to work in a zoneless environment, specifically focusing on migrating existing projects and adopting best practices.

Setting Up a Zoneless Project (or Checking Your New Project)

If you create a brand new Angular v21 project, it will be zoneless by default.

Let’s quickly create a new project to confirm this:

ng new angular-zoneless-demo --standalone --routing --strict
cd angular-zoneless-demo

Now, open src/app/app.config.ts. You will likely NOT see an explicit provideExperimentalZonelessChangeDetection() or provideZoneChangeDetection(). Angular v21 now handles zoneless as the default behavior.

However, if you wanted to re-introduce Zone.js (e.g., for compatibility reasons during a phased migration), you would add provideZoneChangeDetection():

// src/app/app.config.ts (if you *wanted* to re-introduce Zone.js temporarily)
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection(), // Explicitly providing Zone.js behavior
    provideRouter(routes)
  ]
};

For the rest of this chapter, assume we are in a default Angular v21 project without provideZoneChangeDetection() – a truly zoneless environment.

Migrating an Existing Project to Zoneless

When you run ng update @angular/core@21 @angular/cli@21, the Angular CLI will handle most of the heavy lifting. It will analyze your project for Zone.js dependencies.

Migration Steps from a Zone.js-based Angular v20 Project (or earlier):

  1. Update Angular:

    ng update @angular/core@21 @angular/cli@21
    

    The CLI will prompt you for any migrations. It will automatically add provideZoneChangeDetection() if it detects Zone.js usage to ensure your app still works. The goal then becomes to remove this line!

  2. Remove Zone.js from polyfills.ts (if present): If your project has a polyfills.ts file and it imports zone.js, you’ll want to remove it.

    // src/polyfills.ts (REMOVE THIS LINE if present and you want to be fully zoneless)
    // import 'zone.js';
    
  3. Remove zone.js from angular.json: Look for zone.js in the polyfills array within your angular.json file. Remove it for both build and test configurations.

    // angular.json
    "architect": {
      "build": {
        "options": {
          "polyfills": [
            // REMOVE THIS LINE if present
            // "zone.js"
          ],
    
  4. Remove provideZoneChangeDetection() from app.config.ts (or AppModule): Once you’ve removed Zone.js polyfills and feel confident, remove the explicit provideZoneChangeDetection() call that the CLI might have added.

    // src/app/app.config.ts
    import { ApplicationConfig } from '@angular/core'; // <-- Remove provideZoneChangeDetection import
    import { provideRouter } from '@angular/router';
    
    import { routes } from './app.routes';
    
    export const appConfig: ApplicationConfig = {
      providers: [
        // REMOVE THIS LINE if present
        // provideZoneChangeDetection(),
        provideRouter(routes)
      ]
    };
    

Working with Zoneless Change Detection - The Core Changes

In a zoneless application, you need to be more intentional about triggering change detection. This is a good thing, leading to better performance and predictability.

1. Signals are Your Best Friend: Signals are the primary mechanism for reactive state management and automatic UI updates in zoneless Angular. When a signal is updated, any component template that directly reads that signal will automatically re-render.

// src/app/counter/counter.component.ts
import { Component, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-counter',
  standalone: true,
  imports: [CommonModule],
  template: `
    <h2>Counter: {{ count() }}</h2>
    <p>Double: {{ doubledCount() }}</p>
    <button (click)="increment()">Increment</button>
  `,
  styles: [`
    :host { display: block; padding: 20px; border: 1px solid #ccc; margin: 10px; }
    button { padding: 8px 15px; background-color: #007bff; color: white; border: none; cursor: pointer; }
  `]
})
export class CounterComponent {
  count = signal(0);
  doubledCount = computed(() => this.count() * 2);

  increment() {
    this.count.update(currentCount => currentCount + 1);
    // UI automatically updates because 'count' signal is read in the template.
    // No Zone.js or explicit change detection trigger needed here!
  }
}

Explanation:

  • count is a signal initialized to 0.
  • doubledCount is a computed signal that depends on count.
  • When increment() is called, count.update() changes the signal’s value.
  • Because count() and doubledCount() are read in the template, Angular’s zoneless change detection automatically updates the displayed values without Zone.js’s global patching. This is efficient because only the parts of the DOM affected by these signals are re-evaluated.

2. Template Events Still Work: User interaction events like (click), (input), (submit) will still trigger change detection for the component where the event originated, and its ancestors if their OnPush strategy isn’t stopping it.

3. async Pipe Remains Powerful: The async pipe still subscribes to Observables and triggers change detection when new values are emitted. This is a robust way to integrate RxJS with zoneless components.

// src/app/data-fetcher/data-fetcher.component.ts
import { Component, inject, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http'; // HttpClient is by default in v21!
import { Observable } from 'rxjs';
import { CommonModule } from '@angular/common';

interface User {
  id: number;
  name: string;
}

@Component({
  selector: 'app-data-fetcher',
  standalone: true,
  imports: [CommonModule],
  template: `
    <h2>Users from API</h2>
    <div *ngIf="users$ | async as users">
      <ul>
        <li *ngFor="let user of users">{{ user.name }}</li>
      </ul>
    </div>
    <div *ngIf="!(users$ | async)">Loading users...</div>
  `,
})
export class DataFetcherComponent implements OnInit {
  private http = inject(HttpClient);
  users$: Observable<User[]>;

  ngOnInit() {
    // This assumes a JSON server or similar running at /api/users
    this.users$ = this.http.get<User[]>('/api/users');
  }
}

Explanation:

  • HttpClient is automatically available in v21.
  • users$ is an Observable. The async pipe handles the subscription and explicitly informs Angular when a new value arrives, triggering change detection for this component.

4. Explicitly Triggering Change Detection (markForCheck): While less common in a purely signal-driven architecture, there might be scenarios (e.g., integrating with a legacy library, or imperative state updates outside of Angular’s control) where you need to manually tell Angular to check for changes.

// src/app/legacy-wrapper/legacy-wrapper.component.ts
import { Component, ChangeDetectionStrategy, ChangeDetectorRef, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-legacy-wrapper',
  standalone: true,
  imports: [CommonModule],
  template: `
    <p>Value from legacy system: {{ legacyValue }}</p>
    <button (click)="updateLegacyValue()">Update Legacy Value</button>
  `,
  // Using OnPush is highly recommended with zoneless
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LegacyWrapperComponent implements OnInit {
  legacyValue: string = 'Initial';
  private externalTimerId: any;

  constructor(private cdr: ChangeDetectorRef) {}

  ngOnInit(): void {
    // Simulate an external, non-Angular-aware system updating a value
    this.externalTimerId = setInterval(() => {
      this.legacyValue = `Updated at ${new Date().toLocaleTimeString()}`;
      console.log('Legacy value updated:', this.legacyValue);
      // We must explicitly tell Angular to check for changes
      this.cdr.markForCheck();
    }, 2000);
  }

  updateLegacyValue() {
    // This interaction event automatically triggers change detection for this component.
    this.legacyValue = `Manually updated at ${new Date().toLocaleTimeString()}`;
  }

  ngOnDestroy(): void {
    clearInterval(this.externalTimerId);
  }
}

Explanation:

  • This component uses OnPush change detection, which means it won’t automatically update unless an input changes or an event originates from within it.
  • The setInterval is a browser API that traditionally Zone.js would patch. In a zoneless app, it doesn’t.
  • When legacyValue is updated inside setInterval, Angular has no knowledge of it unless we call this.cdr.markForCheck(). This explicitly marks the component (and its ancestors if needed) as dirty, prompting Angular to check for changes and update the UI.
  • The updateLegacyValue() button click does trigger change detection because it’s a template event.

Best Practices for Zoneless Angular

  1. Embrace Signals: Design your component state primarily with signals. This is the most efficient and idiomatic way to handle reactivity in a zoneless world.
  2. Use OnPush Everywhere: Make ChangeDetectionStrategy.OnPush the default for your components. This aligns perfectly with the explicit nature of zoneless change detection and prevents unnecessary checks.
  3. Prefer async Pipe for Observables: It handles subscriptions and change detection triggering cleanly.
  4. Be Mindful of External Libraries: If integrating with libraries that modify the DOM or state outside of Angular’s knowledge (e.g., some canvas libraries, certain jQuery plugins), you might need markForCheck() more frequently.
  5. Test Thoroughly: While zoneless simplifies things, it changes fundamental behavior. Ensure your unit and end-to-end tests cover scenarios where change detection was implicitly relied upon before.

Mini-Challenge: Observe Zoneless Behavior

Create a new Angular v21 standalone component called TimerComponent.

  1. Initialize a time signal with the current time.
  2. Use setInterval to update this time signal every second.
  3. Display the time in the template.
  4. Run the application. Does the time update every second? (Hint: Yes, because you are updating a signal directly).
  5. Now, try to do the same but update a plain variable (e.g., plainTime: string = '';) inside setInterval.
  6. Display both the signal-driven time and the plain variable time.
  7. What do you observe about the plainTime? How would you make plainTime update its display? (Think markForCheck())
// HINT for TimerComponent (do not copy-paste, try to implement yourself first!)
import { Component, signal, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-timer',
  standalone: true,
  imports: [CommonModule],
  template: `
    <h3>Signal-driven Time: {{ signalTime() }}</h3>
    <h3>Plain Variable Time: {{ plainTime }}</h3>
    <button (click)="forceUpdate()">Force Update Plain Time</button>
  `,
  // Consider ChangeDetectionStrategy.OnPush here for more explicit control
  // changeDetection: ChangeDetectionStrategy.OnPush
})
export class TimerComponent implements OnInit, OnDestroy {
  signalTime = signal(new Date().toLocaleTimeString());
  plainTime: string = new Date().toLocaleTimeString();
  private signalIntervalId: any;
  private plainIntervalId: any;

  constructor(private cdr: ChangeDetectorRef) {} // Inject ChangeDetectorRef

  ngOnInit(): void {
    // This will update automatically due to signal reactivity
    this.signalIntervalId = setInterval(() => {
      this.signalTime.set(new Date().toLocaleTimeString());
    }, 1000);

    // This will NOT update automatically without markForCheck()
    this.plainIntervalId = setInterval(() => {
      this.plainTime = new Date().toLocaleTimeString();
      // Uncomment the line below to see it update
      // this.cdr.markForCheck();
    }, 1000);
  }

  forceUpdate() {
    // This button click will trigger change detection for the component
    // and thus update plainTime if you didn't markForCheck in the interval.
    this.plainTime = `Force update: ${new Date().toLocaleTimeString()}`;
  }

  ngOnDestroy(): void {
    clearInterval(this.signalIntervalId);
    clearInterval(this.plainIntervalId);
  }
}

Summary/Key Takeaways

  • Angular v21 makes zoneless change detection the default for new projects, eliminating Zone.js overhead.
  • Migrating involves removing zone.js polyfills and the provideZoneChangeDetection() provider.
  • In a zoneless world, Signals are the primary drivers of UI updates, automatically triggering change detection where their values are read.
  • Template events and the async pipe also trigger change detection.
  • Use ChangeDetectorRef.markForCheck() for explicitly triggering change detection in specific, non-signal-driven scenarios.
  • Adopt OnPush change detection strategy for all components to maximize performance benefits.

By internalizing these practices, you’ll be well on your way to building highly performant and predictable Angular applications with v21!