Zoneless Angular - Production Ready (v21)

📚 Zoneless is Stable!

Angular 21 marks zoneless mode as production-ready:

  • No Zone.js: Remove 12KB+ from bundle
  • Better performance: No unnecessary change detection
  • Signal-driven: Components update via signals
  • Simpler debugging: Stack traces without Zone patches
  • Framework integration: Works with all Angular features

🛤️ Migration Path

1

Enable OnPush everywhere

All components use ChangeDetectionStrategy.OnPush

2

Convert to Signals

Replace mutable properties with signal(), input(), model()

3

Remove async triggers

Replace setTimeout-based updates with signal updates

4

Enable zoneless

Add provideZonelessChangeDetection() to config

🎯 Interview Questions

  • Q1: What triggers change detection in zoneless mode?
  • A: Signal changes, event handlers (still work!), async pipe, markForCheck(). NOT setTimeout/setInterval or plain property assignments.
  • Q2: Do event handlers still work without Zone.js?
  • A: Yes! Angular's event handlers (() = method()) automatically trigger change detection.
  • Q3: What about third-party libraries?
  • A: Libraries that rely on Zone.js patching need updates. Signal-based libraries work fine.
  • Q4: How much smaller is the bundle?
  • A: Zone.js is ~12-15KB gzipped. Removing it also improves startup time.

🔧 Zoneless-Ready Demo

Signal-Based Counter (Zoneless Ready)

0

✅ Event handlers work in zoneless

✅ Signal updates trigger change detection

✅ OnPush strategy enabled

What Would Break Without Signals?

// ❌ This won't update UI in zoneless:

setTimeout(() => {
  this.value = 'new'; // Plain property!
}, 1000);
              

// ✅ This works in zoneless:

setTimeout(() => {
  this.value.set('new'); // Signal!
}, 1000);
              

✅ Zoneless Readiness Checklist

Progress: 0/6 items

💻 Zoneless Setup Code


// ===== Enable Zoneless in Angular 21+ =====

// app.config.ts
import { 
  ApplicationConfig,
  provideZonelessChangeDetection 
} from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(), // That's it!
    provideRouter(routes),
    provideHttpClient(),
  ]
};

// ===== Remove zone.js from polyfills =====

// angular.json - remove from polyfills array
{
  "build": {
    "options": {
      "polyfills": [
        // "zone.js"  <-- Remove this!
      ]
    }
  }
}

// Or in package.json, remove zone.js dependency entirely

// ===== Component Patterns for Zoneless =====

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush, // Required!
  template: `
    <button (click)="increment()">Count: {{ count() }}</button>
  `
})
export class ZonelessComponent {
  count = signal(0);
  
  // ✅ Event handlers work - Angular handles them
  increment() {
    this.count.update(v => v + 1);
  }
  
  // ✅ Signal updates trigger change detection
  loadData() {
    fetch('/api/data')
      .then(res => res.json())
      .then(data => this.data.set(data)); // Signal update!
  }
  
  // ✅ Using resource() - recommended for HTTP
  data = resource({
    loader: async () => {
      const res = await fetch('/api/data');
      return res.json();
    }
  });
}

// ===== What DOESN'T Work Without Signals =====

@Component({...})
export class BrokenZonelessComponent {
  value = 'initial'; // ❌ Plain property
  
  updateLater() {
    // ❌ Won't trigger UI update in zoneless!
    setTimeout(() => {
      this.value = 'updated';
    }, 1000);
  }
  
  // ❌ Interval won't update UI
  ngOnInit() {
    setInterval(() => {
      this.value = 'ticking'; // Nothing happens!
    }, 1000);
  }
}

// ===== Fixed Version =====

@Component({...})
export class FixedZonelessComponent {
  value = signal('initial'); // ✅ Signal
  
  updateLater() {
    // ✅ Works! Signal update triggers CD
    setTimeout(() => {
      this.value.set('updated');
    }, 1000);
  }
  
  // ✅ Effect with cleanup for intervals
  constructor() {
    effect((onCleanup) => {
      const interval = setInterval(() => {
        this.value.set('ticking'); // Works!
      }, 1000);
      onCleanup(() => clearInterval(interval));
    });
  }
}

// ===== Testing Without Zone =====

// Use async/await instead of fakeAsync
describe('ZonelessComponent', () => {
  it('should update count', async () => {
    const fixture = TestBed.createComponent(ZonelessComponent);
    const component = fixture.componentInstance;
    
    component.increment();
    fixture.detectChanges(); // Manual CD trigger
    
    expect(component.count()).toBe(1);
  });
});

// ===== Manual Change Detection (if needed) =====

@Component({...})
export class ManualCDComponent {
  private cdr = inject(ChangeDetectorRef);
  
  // For rare cases where you need manual trigger
  forceUpdate() {
    this.cdr.markForCheck();
    // or
    this.cdr.detectChanges();
  }
}

// ===== Framework Features That Work in Zoneless =====

// ✅ Event bindings ((click), (input), etc.)
// ✅ Signal updates
// ✅ Async pipe (internally uses markForCheck)
// ✅ Router navigation
// ✅ Form controls (reactive & template-driven)
// ✅ Animations
// ✅ HTTP interceptors
// ✅ resource() / httpResource()