Munsif.
AboutExperienceProjectsAchievementsBlogsContact
HomeAboutExperienceProjectsAchievementsBlogsContact
Munsif.

Frontend Developer crafting scalable web applications with modern technologies and clean code practices.

Quick Links

  • About
  • Experience
  • Projects
  • Achievements
  • Blogs
  • Contact

Connect

© 2026 Shaik Munsif. All rights reserved.

Built with Next.js & Tailwind

0%
Welcome back!Continue where you left off
Back to Blogs
Angular

Angular Without Zone.js: Signals, New Control Flow & Zoneless Change Detection

Angular's biggest architectural shift is here. Learn how Signals replace Zone.js, master the new @if/@for control flow, signal-based component inputs, @defer lazy loading, and the migration path from zone-based to zoneless apps.

May 23, 202625 min read
AngularSignalsZonelessChange DetectionAngular 19Angular 20Angular 21Modern Angular

Angular Without Zone.js: Signals, New Control Flow & Zoneless Change Detection

Angular has undergone its biggest architectural shift since its creation. Zone.js — the library that powered Angular's change detection for over a decade — is being replaced by a fine-grained, signal-based reactivity system. Angular 21 now creates zoneless projects by default.

This guide covers everything you need to know: why this change happened, how Signals work, the new template syntax, and how to migrate your existing apps.


Why Angular Is Dropping Zone.js

Analogy: The Security Guard vs The Motion Sensor

Zone.js is like a security guard who walks through every room in your building every time anything changes — even a light flicker in a storage closet nobody uses. The guard checks every door, every window, every desk. Thorough, but slow.

Signals are like motion sensors — they only trigger an alert in the exact room where movement happens. No wasted patrols, no false alarms.

What Zone.js Actually Does

Zone.js monkey-patches every async browser API — setTimeout, addEventListener, Promise, fetch, and more. Whenever any of these complete, Zone.js tells Angular: "Something changed. Check everything."

This works, but has real costs:

  • Performance: Every change triggers a full component tree check, not just what changed
  • Debugging: Stack traces are a nightmare because Zone.js wraps every async call
  • Bundle size: Zone.js adds ~15KB to your production bundle
  • Third-party conflicts: Zone.js can interfere with libraries that also patch browser APIs
  • SSR complexity: Zone.js makes server-side rendering harder to optimize

The Timeline

Angular VersionZoneless Status
Angular 16Signals introduced
Angular 17New control flow, @defer, standalone default
Angular 18Control flow stable, input()/output() stable
Angular 19Zoneless experimental, input()/output() default
Angular 20Zoneless developer preview, effect()/linkedSignal() stable
Angular 20.2Zoneless stable
Angular 21Zoneless default for new projects

Angular Signals: The Foundation

Signals are reactive primitives that tell Angular exactly what changed — no zone.js needed.

Creating Signals

typescript
import { signal, computed, effect } from '@angular/core';

// Writable signal
const count = signal(0);

// Read the value
console.log(count()); // 0

// Update the value
count.set(5);
count.update(prev => prev + 1); // Now 6

Computed Signals (Derived State)

Computed signals automatically recalculate when their dependencies change.

typescript
const firstName = signal('Shaik');
const lastName = signal('Munsif');

// Automatically updates when firstName or lastName changes
const fullName = computed(() => `${firstName()} ${lastName()}`);

Before (without Signals):

typescript
@Component({
  template: '{{ getFullName() }}' // Runs on EVERY change detection cycle
})
export class NameComponent {
  @Input() firstName = '';
  @Input() lastName = '';

  getFullName() {
    return `${this.firstName} ${this.lastName}`; // Called hundreds of times
  }
}

After (with Signals):

typescript
@Component({
  template: '{{ fullName() }}', // Only runs when firstName or lastName changes
  signals: true
})
export class NameComponent {
  firstName = input.required<string>();
  lastName = input.required<string>();

  fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
}

Effects (Side Effects)

Effects run a function when any tracked signal changes. Stable since Angular 20.

typescript
constructor() {
  effect(() => {
    console.log(`Count changed to: ${this.count()}`);
    // This automatically tracks count() as a dependency
  });
}

When to use effects vs computed:

  • Use computed() when you derive a value — it returns a signal
  • Use effect() when you perform a side effect (logging, DOM manipulation, syncing to localStorage)
  • Never use effects to set other signals — use computed() instead

linkedSignal (Two Signals Linked Together)

linkedSignal creates a writable signal that resets when a source signal changes. Stable since Angular 20.

typescript
const selectedUserId = signal(1);
const editedName = linkedSignal({
  source: selectedUserId,
  computation: (newId) => {
    // Reset the edit field when a different user is selected
    return users.find(u => u.id === newId)?.name ?? '';
  }
});

// User can type in the field (writable)
editedName.set('New Name');

// But switching users resets it
selectedUserId.set(2); // editedName resets to user 2's name
🌍
Real-World Example: Think of a form with a "Cancel" button. linkedSignal gives you the "reset to original" behavior for free.

Signal Comparison Table

APIPurposeWritable?Stable Since
signal()Hold a reactive valueYesAngular 16
computed()Derive value from other signalsNoAngular 16
effect()Run side effect on changeNoAngular 20
linkedSignal()Writable signal that resets from sourceYesAngular 20
toSignal()Convert Observable to SignalNoAngular 16
resource()Async data loadingNoExperimental
httpResource()HTTP-based resource loadingNoExperimental

New Template Control Flow

Angular replaced *ngIf, *ngFor, and *ngSwitch with built-in @ syntax. Stable since Angular 18.

@if / @else (replaces *ngIf)

Before:

html
<div *ngIf="user; else noUser">
  <p>Welcome, {{ user.name }}</p>
</div>
<ng-template #noUser>
  <p>No user found</p>
</ng-template>

After:

html
@if (user) {
  <p>Welcome, {{ user.name }}</p>
} @else {
  <p>No user found</p>
}

No more ng-template, no more template reference variables for simple conditionals.

@for / @empty (replaces *ngFor)

Before:

html
<ul>
  <li *ngFor="let item of items; trackBy: trackById">
    {{ item.name }}
  </li>
</ul>

After:

html
<ul>
  @for (item of items; track item.id) {
    <li>{{ item.name }}</li>
  } @empty {
    <li>No items found</li>
  }
</ul>

Key differences:

  • track replaces trackBy — write the expression directly, no separate method needed
  • @empty block handles the empty list case inline
  • Built-in $index, $first, $last, $even, $odd context variables
  • track is required — Angular enforces it for performance (no more silent re-renders)

@switch (replaces ngSwitch)

Before:

html
<div [ngSwitch]="status">
  <p *ngSwitchCase="'loading'">Loading...</p>
  <p *ngSwitchCase="'error'">Error!</p>
  <p *ngSwitchDefault>Content here</p>
</div>

After:

html
@switch (status) {
  @case ('loading') {
    <p>Loading...</p>
  }
  @case ('error') {
    <p>Error!</p>
  }
  @default {
    <p>Content here</p>
  }
}

Why the New Syntax Is Better

FeatureOld (*ngIf/*ngFor)New (@if/@for)
Imports neededNgIf, NgFor, NgSwitchNone — built-in
Empty stateManual check@empty block
TrackingtrackBy: methodtrack expr inline
Type narrowingNoYes — @if narrows types
PerformanceGoodBetter (optimized at compile time)

Signal-Based Components

input() — Replaces @Input()

Before:

typescript
@Input() userId!: number;
@Input() userName: string = '';
@Input({ required: true }) email!: string;

After:

typescript
// Optional with default
userId = input<number>(0);
userName = input<string>('');

// Required — compile error if parent doesn't pass it
email = input.required<string>();

// Use in template: {{ userId() }}

Signal inputs are signals — they work with computed() and effect().

output() — Replaces @Output()

Before:

typescript
@Output() itemDeleted = new EventEmitter<number>();

After:

typescript
itemDeleted = output<number>();
// Usage is identical: this.itemDeleted.emit(42);

model() — Two-Way Binding with Signals

Before:

typescript
@Input() value = '';
@Output() valueChange = new EventEmitter<string>();

After:

typescript
value = model(''); // Signal that parent can also write to

Parent usage stays the same: [(value)]="myVar" or value="myVar" (valueChange)="myVar = $event".

🌍
Real-World Example: Building a Search Component
typescript
@Component({
  selector: 'app-search',
  template: `
    <input
      [value]="query()"
      (input)="query.set($any($event.target).value)"
      placeholder="Search..."
    >
    <p>{{ results().length }} results found</p>
  },
  signals: true
})
export class SearchComponent {
  query = model(''); // Two-way bindable

  results = computed(() =>
    this.allItems().filter(item =>
      item.name.toLowerCase().includes(this.query().toLowerCase())
    )
  );

  allItems = input.required<Item[]>();
}

Parent uses it naturally:

html
<app-search [(query)]="searchText" [allItems]="products" />

Component Migration Cheat Sheet

OldNewNotes
@Input() x = ''x = input('')Now a signal: x()
@Input({ required }) x!x = input.required()Compile-time safety
@Output() x = new EventEmitter()x = output()Same emit syntax
@Input() + @Output() xChangex = model('')Two-way binding
ngOnChangeseffect()React to input changes
@ViewChildviewChild() signal queryAngular 17+

@defer: Lazy Loading Without Router

The @defer block lazy-loads components on demand — no route-level code splitting needed.

html
@defer (on viewport) {
  <app-heavy-chart [data]="chartData()" />
} @loading (minimum 500ms) {
  <div class="skeleton">Loading chart...</div>
} @placeholder {
  <div class="placeholder">Chart will appear here</div>
} @error {
  <p>Failed to load chart. <button (click)="retry()">Retry</button></p>
}

Available Triggers

TriggerWhen it loadsUse case
on viewportScrolls into viewBelow-the-fold sections
on idleBrowser is idle (default)Non-critical components
on interactionUser clicks/tapsModals, panels
on hoverUser hoversDropdowns, previews
on immediateAfter parent rendersPrioritized loading
on timer(5s)After delayDelayed tooltips
when exprCustom conditionPermission-gated content

Triggers can be combined: @defer (on viewport and when isLoggedIn()) { ... }

You can also prefetch before loading:

html
@defer (on interaction; prefetch on hover) {
  <app-edit-panel />
}

This prefetches on hover (lightweight), then fully loads on click. Users perceive instant loading.


Zoneless Change Detection

How to Enable It

Angular 21+ (new projects): Already the default. No configuration needed.

Existing projects (Angular 20+):

typescript
// main.ts
bootstrapApplication(AppComponent, {
  providers: [
    provideZonelessChangeDetection(),
    provideBrowserGlobalErrorListeners(),
  ],
});

Then remove zone.js from angular.json polyfills.

What Changes Without Zone.js

AspectWith Zone.jsWithout Zone.js
Change detection triggerAny async eventSignal/zone-free event
ScopeEntire component treeOnly affected components
Third-party libsWork automatically (zone patches everything)Must call ChangeDetectorRef.detectChanges() or use signals
setTimeout/fetch updatesAutomaticWrap in NgZone.run() or convert to signals
Bundle size+15KB (zone.js)No zone.js
DebuggingComplex stack tracesClean stack traces
SSRZone-based hydrationIncremental hydration

What You Need to Change

  1. Use signals for component state — signal() instead of plain properties
  2. Use signal inputs — input() instead of @Input()
  3. Replace ngOnChanges — with effect() or computed()
  4. Wrap third-party async calls — Use NgZone.run() or convert to signals
  5. Update tests — Call ComponentFixture.detectChanges() manually after async operations

Real-World Example: HTTP Calls Without Zone.js

Before (zone-based):

typescript
export class UserService {
  users: User[] = [];

  constructor(private http: HttpClient) {}

  loadUsers() {
    this.http.get<User[]>('/api/users').subscribe(users => {
      this.users = users; // Zone.js triggers change detection
    });
  }
}

After (zoneless with signals):

typescript
export class UserService {
  private usersSignal = signal<User[]>([]);
  users = this.usersSignal.asReadonly();

  constructor(private http: HttpClient) {}

  loadUsers() {
    this.http.get<User[]>('/api/users').subscribe(users => {
      this.usersSignal.set(users); // Signal triggers change detection
    });
  }
}

The signal update tells Angular exactly what changed — no zone.js needed.


Migration Guide: Zone-Based to Zoneless

Step 1: Adopt Signals for State

typescript
// Before
export class CartComponent {
  items: Item[] = [];
  total = 0;

  addItem(item: Item) {
    this.items.push(item);
    this.total = this.items.reduce((sum, i) => sum + i.price, 0);
  }
}

// After
export class CartComponent {
  items = signal<Item[]>([]);
  total = computed(() => this.items().reduce((sum, i) => sum + i.price, 0));

  addItem(item: Item) {
    this.items.update(items => [...items, item]);
  }
}

Step 2: Convert Inputs and Outputs

typescript
// Before
@Input() productId!: string;
@Output() added = new EventEmitter<Item>();

// After
productId = input.required<string>();
added = output<Item>();

Step 3: Replace *ngIf / *ngFor with New Control Flow

Use the Angular CLI migration:

bash
ng generate @angular/core:control-flow-migration

Or manually convert *ngIf → @if, *ngFor → @for.

Step 4: Enable Zoneless

typescript
// main.ts
bootstrapApplication(AppComponent, {
  providers: [
    provideZonelessChangeDetection(),
    provideBrowserGlobalErrorListeners(),
  ],
});

Remove from angular.json:

json
{
  "polyfills": ["zone.js"]  // Remove this line
}

Step 5: Test and Fix

bash
ng test

Watch for:

  • Views not updating after async operations → convert to signals
  • Third-party library callbacks not triggering updates → wrap in NgZone.run()
  • Tests failing → add manual fixture.detectChanges() calls

Migration Decision Tree


Standalone Components (No More NgModules)

Since Angular 19, standalone: true is the default. You don't need to write it.

Before (NgModule-based):

typescript
@NgModule({
  declarations: [HeaderComponent, FooterComponent],
  imports: [CommonModule, RouterModule],
  exports: [HeaderComponent, FooterComponent]
})
export class SharedModule {}

After (Standalone):

typescript
@Component({
  selector: 'app-header',
  standalone: true, // Optional in Angular 19+ — it's the default
  imports: [RouterLink], // Import what you need directly
  template: `<nav><a routerLink="/">Home</a></nav>`
})
export class HeaderComponent {}

Import directly where needed:

typescript
@Component({
  imports: [HeaderComponent], // Use it like any other import
  template: `<app-header />`
})
export class AppComponent {}

NgModules are still supported but not recommended for new code.


Quick Reference: Before & After

ConceptBefore (Zone-Based)After (Zoneless/Signals)
Component statethis.count = 0count = signal(0)
Read state{{ count }}{{ count() }}
Update statethis.count++this.count.update(v => v + 1)
Derived stateget total() { ... }total = computed(() => ...)
Side effectsngOnChanges()effect(() => ...)
Input@Input() x = ''x = input('')
Required input@Input({ required: true }) x!x = input.required()
Output@Output() x = new EventEmitter()x = output()
Two-way binding@Input() + @Output()x = model('')
Conditional*ngIf="x"@if (x) { ... }
Loop*ngFor="let x of items"@for (x of items; track x.id) { ... }
Switch[ngSwitch]="x"@switch (x) { ... }
Lazy loadRoute-level only@defer (on viewport) { ... }
Change detectionZone.js (automatic, full tree)Signals (targeted, fine-grained)

Summary

Angular's shift from zone.js to signals is the biggest architectural change in the framework's history. Here's what matters:

  1. Signals are production-ready — signal(), computed(), effect(), input(), output(), model() are all stable
  2. Zoneless is the default in Angular 21+ — new projects are zoneless out of the box
  3. New control flow (@if, @for, @switch) is cleaner, faster, and requires zero imports
  4. @defer gives you component-level lazy loading without route changes
  5. Migration is incremental — you don't have to change everything at once

The Angular of 2026 is leaner, faster, and more predictable. If you're starting a new project, you get all of this by default. If you're maintaining an existing app, the migration path is well-supported with automated tooling.

🧠 Test Your Knowledge

Now that you've learned the concepts, let's see if you can apply them! Take this quick quiz to test your understanding.

PreviousSOLID Principles in Angular: An In-Depth Guide with Real-World Examples

Written by

Shaik Munsif

Read more articles

Found this helpful? Share it with your network!

On this page

0/37
Question 1 of 7Easy
Score: 0/0

What does Zone.js do in traditional Angular applications?