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. Master Signals (signal, computed, effect, linkedSignal), new @if/@for/@switch control flow, signal-based components (input, output, model, viewChild), @defer lazy loading, zoneless change detection internals, and the complete migration path from zone-based to zoneless apps.

May 23, 202630 min read
AngularSignalsZonelessChange DetectionAngular 19Angular 20Angular 21Modern Angular
Angular Modern FeaturesPart 2 of 4
  • 1. Angular Signals: The Complete Guide from Beginner to Advanced
  • 2. Angular Without Zone.js: Signals, New Control Flow & Zoneless Change Detection
  • 3. Angular Signal Forms: The In-Depth 'Zero to Hero' Guide
  • 4. Angular Resource API: Master resource(), rxResource() & httpResource() from Scratch

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

Angular has undergone the biggest architectural shift in its history. Zone.js — the monkey-patching library that powered Angular's change detection for over a decade — is gone. Angular 21 creates zoneless projects by default, and the entire reactivity model has been rebuilt around Signals.

This guide covers the complete picture: why Zone.js was removed, how every Signal API works, the new template syntax, lazy loading with @defer, zoneless change detection internals, and the step-by-step migration path for existing apps.


Why Angular Dropped Zone.js

The Analogy

Think of Zone.js as a night watchman who patrols every single room in a building each time anything stirs anywhere. A light flickers in a storage closet? The watchman walks through all 200 rooms. Thorough, but wasteful.

Signals are motion sensors — they alert you only in the exact room where movement happened. No wasted patrols, no false alarms.

What Zone.js Actually Did

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

This approach worked for a decade but carried real costs:

  • Performance — Every async completion triggers a full component tree traversal, even if only one leaf component changed
  • Debugging — Stack traces become unreadable because Zone.js wraps every async call with its own frames
  • Bundle size — Zone.js adds ~15KB (minified) to production bundles
  • Third-party conflicts — Libraries that also patch browser APIs (e.g., Firebase, gAPI) can clash with Zone.js
  • SSR complexity — Server-side rendering must carefully manage Zone.js zones, making hydration harder
  • Ecosystem friction — Many Node.js libraries and Web APIs simply cannot be monkey-patched, breaking change detection silently

The Timeline

VersionMilestone
Angular 16Signals introduced (signal, computed)
Angular 17New control flow (@if, @for, @switch), @defer, standalone default
Angular 18Control flow and @defer stable; input()/output() stable
Angular 19Zoneless experimental; input()/output() default over decorators
Angular 20Zoneless developer preview; effect()/linkedSignal() stable
Angular 20.2Zoneless stable
Angular 21Zoneless default for new projects; zone.js no longer included

Over 1,400 public Angular apps and hundreds of Google-internal apps already run zoneless in production. The shift is real and battle-tested.


Angular Signals: The Complete API

Signals are reactive primitives that tell Angular exactly what changed — no Zone.js needed. Every signal is a zero-argument getter function that returns the current value. Angular tracks which signals a computation reads, and automatically re-runs that computation when any tracked signal changes.

signal() — Writable State

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

const count = signal(0);

// Read the value (also tracks it in computed/effect)
count(); // 0

// Set a new value
count.set(5);

// Update based on previous value
count.update(prev => prev + 1); // 6

// Mutate in-place (for objects/arrays — triggers change detection)
const items = signal<string[]>([]);
items.mutate(arr => arr.push('new item'));

computed() — Derived State

Computed signals automatically recalculate when their dependencies change. They are read-only — you cannot call .set() on them.

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

const fullName = computed(() => `${firstName()} ${lastName()}`);
fullName(); // 'Shaik Munsif'

firstName.set('Ahmed');
fullName(); // 'Ahmed Munsif' — recomputed automatically

Before Signals — getter runs on EVERY change detection cycle:

typescript
@Component({
  template: '{{ getFullName() }}'
})
export class NameComponent {
  @Input() firstName = '';
  @Input() lastName = '';

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

After Signals — runs only when dependencies actually change:

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

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

effect() — Side Effects

Effects run a function whenever any tracked signal changes. Use them for logging, syncing to localStorage, or DOM manipulation.

typescript
constructor() {
  effect(() => {
    console.log(`Count is now: ${this.count()}`);
    // count() is automatically tracked as a dependency
  });
}

When to use effect() vs computed():

Use computed()Use effect()
You need to derive a valueYou need to perform a side effect
Returns a signalReturns nothing
fullName = computed(() => ...)effect(() => console.log(...))

Never use effect() to set other signals — that creates circular dependencies. Use computed() for derived state instead.

linkedSignal() — Writable Signal That Resets

linkedSignal creates a writable signal that resets to a new value whenever a source signal changes. Think of it as "editable derived state."

typescript
const selectedUserId = signal(1);

const editedName = linkedSignal({
  source: selectedUserId,
  computation: (newId) => {
    return users.find(u => u.id === newId)?.name ?? '';
  }
});

// User can freely edit the field
editedName.set('New Name');

// But switching users resets it to the new user's name
selectedUserId.set(2); // editedName resets to user 2's name

Real-world use case: A form with a "Cancel" button. linkedSignal gives you the "reset to original" behavior for free — the user edits the value, but selecting a different entity resets the form.

Signal API Reference

APIPurposeWritable?Stable Since
signal()Hold a reactive valueYesAngular 16
computed()Derive value from other signalsNoAngular 16
effect()Run side effect when dependencies changeNoAngular 20
linkedSignal()Writable signal that resets from a sourceYesAngular 20
toSignal()Convert Observable → SignalNoAngular 16
toObservable()Convert Signal → ObservableNoAngular 16
resource()Async data loading with signalsNoExperimental
httpResource()Declarative HTTP calls with signalsNoExperimental

New Template Control Flow

Angular replaced *ngIf, *ngFor, and *ngSwitch with built-in @ syntax. No imports needed, better performance, and type narrowing.

@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 if (guest) {
  <p>Welcome, guest</p>
} @else {
  <p>No user found</p>
}

Key improvements:

  • No ng-template or template reference variables needed
  • @else if chains — not possible with *ngIf
  • Type narrowing — inside @if (user), TypeScript knows user is not null/undefined

@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
  • @empty handles the empty-list case inline
  • Built-in context variables: $index, $first, $last, $even, $odd
  • track is required — Angular enforces it for performance (no more silent full 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>
  }
}

@let — Template Variables

New in Angular 18+, @let declares reusable template variables:

html
@let formattedDate = formatDate(item.date());
@let isEven = $index % 2 === 0;

<div [class.even]="isEven">{{ formattedDate }}</div>

Old vs New Syntax Comparison

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

Signal-Based Components

input() — Replaces @Input()

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

// After
userId = input<number>(0);          // Optional with default
userName = input<string>('');        // Optional with default
email = input.required<string>();    // Required — compile error if parent omits it

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

Input transforms are also supported:

typescript
// Transform the input value before it reaches the component
size = input('', { transform: numberAttribute });

output() — Replaces @Output()

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

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

model() — Two-Way Binding

typescript
// Before — required both @Input and @Output
@Input() value = '';
@Output() valueChange = new EventEmitter<string>();

// After — single model() replaces both
value = model('');

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

Signal Queries — Replaces @ViewChild / @ContentChild

typescript
// Before
@ViewChild('dialog') dialog!: ElementRef;

// After
dialog = viewChild<ElementRef>('dialog'); // Returns a signal
Old DecoratorNew Signal Query
@ViewChildviewChild()
@ViewChildrenviewChildren()
@ContentChildcontentChild()
@ContentChildrencontentChildren()

Signal queries return signals, so they compose with computed() and effect().

Real-World Example: 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('');
  allItems = input.required<Item[]>();

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

Parent usage:

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
@ViewChild('ref')viewChild('ref')Returns a signal
@ViewChildren('ref')viewChildren('ref')Returns a signal

@defer: Component-Level Lazy Loading

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 hovers elementDropdowns, previews
on immediateAfter parent rendersPrioritized loading
on timer(5s)After delayDelayed tooltips
when exprCustom condition truePermission-gated content

Triggers can be combined:

html
@defer (on viewport and when isLoggedIn()) {
  <app-admin-panel />
}

Prefetching — load the bundle early, show it later:

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

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

Angular 21+ — Custom viewport options:

html
@defer (on viewport({ rootMargin: '100px' })) {
  <app-heavy-section />
}

Why @defer Matters

Without @defer, every component on the page ships in the initial JavaScript bundle. With @defer, below-the-fold components load only when needed — reducing Time to Interactive (TTI) and improving Core Web Vitals.


Zoneless Change Detection

How It Works

With zoneless, Angular doesn't need to check the entire component tree. When a signal changes, Angular knows exactly which views depend on that signal and updates only those views. This is fine-grained reactivity — the same model used by SolidJS, Vue, and other modern frameworks.

Enabling Zoneless

Angular 21+ (new projects): Already the default. zone.js is not included.

Existing projects (Angular 20.2+):

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 update or zone-free event
ScopeEntire component treeOnly affected components
Third-party libsWork automatically (zone patches everything)Must wrap in NgZone.run() or use signals
setTimeout/fetch updatesAutomaticWrap in NgZone.run() or convert to signals
Bundle size+15KB (zone.js)No zone.js
Stack tracesCluttered with zone framesClean
SSRZone-based hydrationIncremental hydration
Core Web VitalsGoodBetter (less JS to parse)

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 callbacks — in NgZone.run() or convert to signals
  5. Update tests — Call ComponentFixture.detectChanges() manually after async operations

HTTP Calls: Before and After Zoneless

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
    });
  }
}

Third-Party Libraries: The Main Migration Challenge

The biggest pain point when going zoneless is third-party code that updates state outside Angular's knowledge.

typescript
// A charting library calls your callback outside Angular's zone
chartLib.onDataPointClick((data) => {
  // Without Zone.js, Angular doesn't know this happened
  this.selectedPoint.set(data); // ✅ Works — signal updates trigger CD
  // this.selectedPoint = data;  // ❌ Won't update the view
});

If you can't convert to signals, wrap the callback:

typescript
constructor(private ngZone: NgZone) {}

chartLib.onDataPointClick((data) => {
  this.ngZone.run(() => {
    this.selectedPoint = data;
    // Angular will detect this change
  });
});

Experimental: resource() and httpResource()

These APIs are still experimental (as of Angular 21) and their surface may change, but they represent the future of async data loading in Angular.

resource() — Async Data with Signals

typescript
const userId = signal(1);

const userResource = resource({
  params: () => ({ id: userId() }),
  loader: async ({ params }) => {
    const res = await fetch(`/api/users/${params.id}`);
    return res.json();
  }
});

// Access as signals
userResource.value();   // User | undefined
userResource.status();  // 'idle' | 'loading' | 'resolved' | 'error' | 'local'
userResource.error();   // Error | undefined

// Reload
userResource.reload();

httpResource() — Declarative HTTP

typescript
const users = httpResource<User[]>('/api/users');

// Same signal interface
users.value();   // User[] | undefined
users.status();  // 'loading' | 'resolved' | etc.
users.reload();  // Re-fetch

Currently httpResource() only supports GET requests. For POST/PUT/DELETE, continue using HttpClient directly.


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, Outputs, and Queries

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

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

Step 3: Migrate Control Flow

Use the Angular CLI migration:

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

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

Step 4: Migrate Signal Queries

bash
ng generate @angular/core:signal-queries-migration

Step 5: Enable Zoneless

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

Remove from angular.json:

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

Step 6: Test and Fix

bash
ng test

Watch for:

  • Views not updating after async ops → convert to signals
  • Third-party callbacks not triggering updates → wrap in NgZone.run()
  • Tests failing → add manual fixture.detectChanges() calls
  • NgZone not available errors → add provideBrowserGlobalErrorListeners() to test providers

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],
  template: `<nav><a routerLink="/">Home</a></nav>`
})
export class HeaderComponent {}

Import directly where needed:

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

NgModules are still supported but not recommended for new code. The Angular CLI migration handles the conversion automatically via ng update.


Angular 21: What Else Is New

Beyond zoneless defaults, Angular 21 brings several related improvements:

FeatureStatusImpact
Zoneless defaultStableAll new apps are zoneless
Vitest as default test runnerStableFaster tests, modern tooling
Built-in Signals DevTools formatterStableInspect signal values in browser DevTools
MCP Server with zoneless migration toolStableAI-assisted migration planning
Signal Forms (form())ExperimentalSchema-based forms, no ControlValueAccessor needed
Angular AriaDeveloper Preview8 headless accessible UI components

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('')
ViewChild@ViewChild('ref')viewChild('ref')
Conditional*ngIf="x"@if (x) { ... }
Loop*ngFor="let x of items"@for (x of items; track x.id) { ... }
Switch[ngSwitch]="x"@switch (x) { ... }
Template variable—@let x = expr
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 is what matters:

  1. Signals are production-ready — signal(), computed(), effect(), linkedSignal(), input(), output(), model(), and signal queries are all stable
  2. Zoneless is the default in Angular 21+ — new projects are zoneless out of the box, with no zone.js in the bundle
  3. New control flow (@if, @for, @switch, @let) is cleaner, faster, and requires zero imports
  4. @defer gives you component-level lazy loading without route changes
  5. Migration is incremental — adopt signals first, then flip the zoneless switch when ready
  6. resource() and httpResource() are experimental but represent the future of async data loading

The Angular of 2026 is leaner, faster, and more predictable. If you are starting a new project, you get all of this by default. If you are maintaining an existing app, the migration path is well-supported with automated CLI tools and an AI-powered MCP migration assistant.

🧠 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 ExamplesNextAngular Signal Forms: The In-Depth 'Zero to Hero' Guide

Written by

Shaik Munsif

Read more articles

Found this helpful? Share it with your network!

On this page

0/48
Question 1 of 10Easy
Score: 0/0

What does Zone.js do in traditional Angular applications?