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.
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 Version | Zoneless Status |
|---|---|
| Angular 16 | Signals introduced |
| Angular 17 | New control flow, @defer, standalone default |
| Angular 18 | Control flow stable, input()/output() stable |
| Angular 19 | Zoneless experimental, input()/output() default |
| Angular 20 | Zoneless developer preview, effect()/linkedSignal() stable |
| Angular 20.2 | Zoneless stable |
| Angular 21 | Zoneless default for new projects |
Angular Signals: The Foundation
Signals are reactive primitives that tell Angular exactly what changed — no zone.js needed.
Creating Signals
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.
const firstName = signal('Shaik');
const lastName = signal('Munsif');
// Automatically updates when firstName or lastName changes
const fullName = computed(() => `${firstName()} ${lastName()}`);
Before (without Signals):
@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):
@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.
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.
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
linkedSignal gives you the "reset to original" behavior for free.Signal Comparison Table
| API | Purpose | Writable? | Stable Since |
|---|---|---|---|
signal() | Hold a reactive value | Yes | Angular 16 |
computed() | Derive value from other signals | No | Angular 16 |
effect() | Run side effect on change | No | Angular 20 |
linkedSignal() | Writable signal that resets from source | Yes | Angular 20 |
toSignal() | Convert Observable to Signal | No | Angular 16 |
resource() | Async data loading | No | Experimental |
httpResource() | HTTP-based resource loading | No | Experimental |
New Template Control Flow
Angular replaced *ngIf, *ngFor, and *ngSwitch with built-in @ syntax. Stable since Angular 18.
@if / @else (replaces *ngIf)
Before:
<div *ngIf="user; else noUser">
<p>Welcome, {{ user.name }}</p>
</div>
<ng-template #noUser>
<p>No user found</p>
</ng-template>
After:
@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:
<ul>
<li *ngFor="let item of items; trackBy: trackById">
{{ item.name }}
</li>
</ul>
After:
<ul>
@for (item of items; track item.id) {
<li>{{ item.name }}</li>
} @empty {
<li>No items found</li>
}
</ul>
Key differences:
trackreplacestrackBy— write the expression directly, no separate method needed@emptyblock handles the empty list case inline- Built-in
$index,$first,$last,$even,$oddcontext variables trackis required — Angular enforces it for performance (no more silent re-renders)
@switch (replaces ngSwitch)
Before:
<div [ngSwitch]="status">
<p *ngSwitchCase="'loading'">Loading...</p>
<p *ngSwitchCase="'error'">Error!</p>
<p *ngSwitchDefault>Content here</p>
</div>
After:
@switch (status) {
@case ('loading') {
<p>Loading...</p>
}
@case ('error') {
<p>Error!</p>
}
@default {
<p>Content here</p>
}
}
Why the New Syntax Is Better
| Feature | Old (*ngIf/*ngFor) | New (@if/@for) |
|---|---|---|
| Imports needed | NgIf, NgFor, NgSwitch | None — built-in |
| Empty state | Manual check | @empty block |
| Tracking | trackBy: method | track expr inline |
| Type narrowing | No | Yes — @if narrows types |
| Performance | Good | Better (optimized at compile time) |
Signal-Based Components
input() — Replaces @Input()
Before:
@Input() userId!: number;
@Input() userName: string = '';
@Input({ required: true }) email!: string;
After:
// 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:
@Output() itemDeleted = new EventEmitter<number>();
After:
itemDeleted = output<number>();
// Usage is identical: this.itemDeleted.emit(42);
model() — Two-Way Binding with Signals
Before:
@Input() value = '';
@Output() valueChange = new EventEmitter<string>();
After:
value = model(''); // Signal that parent can also write to
Parent usage stays the same: [(value)]="myVar" or value="myVar" (valueChange)="myVar = $event".
@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:
<app-search [(query)]="searchText" [allItems]="products" />
Component Migration Cheat Sheet
| Old | New | Notes |
|---|---|---|
@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() xChange | x = model('') | Two-way binding |
ngOnChanges | effect() | React to input changes |
@ViewChild | viewChild() signal query | Angular 17+ |
@defer: Lazy Loading Without Router
The @defer block lazy-loads components on demand — no route-level code splitting needed.
@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
| Trigger | When it loads | Use case |
|---|---|---|
on viewport | Scrolls into view | Below-the-fold sections |
on idle | Browser is idle (default) | Non-critical components |
on interaction | User clicks/taps | Modals, panels |
on hover | User hovers | Dropdowns, previews |
on immediate | After parent renders | Prioritized loading |
on timer(5s) | After delay | Delayed tooltips |
when expr | Custom condition | Permission-gated content |
Triggers can be combined: @defer (on viewport and when isLoggedIn()) { ... }
You can also prefetch before loading:
@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+):
// main.ts
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
provideBrowserGlobalErrorListeners(),
],
});
Then remove zone.js from angular.json polyfills.
What Changes Without Zone.js
| Aspect | With Zone.js | Without Zone.js |
|---|---|---|
| Change detection trigger | Any async event | Signal/zone-free event |
| Scope | Entire component tree | Only affected components |
| Third-party libs | Work automatically (zone patches everything) | Must call ChangeDetectorRef.detectChanges() or use signals |
setTimeout/fetch updates | Automatic | Wrap in NgZone.run() or convert to signals |
| Bundle size | +15KB (zone.js) | No zone.js |
| Debugging | Complex stack traces | Clean stack traces |
| SSR | Zone-based hydration | Incremental hydration |
What You Need to Change
- Use signals for component state —
signal()instead of plain properties - Use signal inputs —
input()instead of@Input() - Replace
ngOnChanges— witheffect()orcomputed() - Wrap third-party async calls — Use
NgZone.run()or convert to signals - Update tests — Call
ComponentFixture.detectChanges()manually after async operations
Real-World Example: HTTP Calls Without Zone.js
Before (zone-based):
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):
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
// 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
// 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:
ng generate @angular/core:control-flow-migration
Or manually convert *ngIf → @if, *ngFor → @for.
Step 4: Enable Zoneless
// main.ts
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
provideBrowserGlobalErrorListeners(),
],
});
Remove from angular.json:
{
"polyfills": ["zone.js"] // Remove this line
}
Step 5: Test and Fix
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):
@NgModule({
declarations: [HeaderComponent, FooterComponent],
imports: [CommonModule, RouterModule],
exports: [HeaderComponent, FooterComponent]
})
export class SharedModule {}
After (Standalone):
@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:
@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
| Concept | Before (Zone-Based) | After (Zoneless/Signals) |
|---|---|---|
| Component state | this.count = 0 | count = signal(0) |
| Read state | {{ count }} | {{ count() }} |
| Update state | this.count++ | this.count.update(v => v + 1) |
| Derived state | get total() { ... } | total = computed(() => ...) |
| Side effects | ngOnChanges() | 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 load | Route-level only | @defer (on viewport) { ... } |
| Change detection | Zone.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:
- Signals are production-ready —
signal(),computed(),effect(),input(),output(),model()are all stable - Zoneless is the default in Angular 21+ — new projects are zoneless out of the box
- New control flow (
@if,@for,@switch) is cleaner, faster, and requires zero imports @defergives you component-level lazy loading without route changes- 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.