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.
- 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
| Version | Milestone |
|---|---|
| Angular 16 | Signals introduced (signal, computed) |
| Angular 17 | New control flow (@if, @for, @switch), @defer, standalone default |
| Angular 18 | Control flow and @defer stable; input()/output() stable |
| Angular 19 | Zoneless experimental; input()/output() default over decorators |
| Angular 20 | Zoneless developer preview; effect()/linkedSignal() stable |
| Angular 20.2 | Zoneless stable |
| Angular 21 | Zoneless 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
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.
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:
@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:
@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.
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 value | You need to perform a side effect |
| Returns a signal | Returns 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."
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
| 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 when dependencies change | No | Angular 20 |
linkedSignal() | Writable signal that resets from a source | Yes | Angular 20 |
toSignal() | Convert Observable → Signal | No | Angular 16 |
toObservable() | Convert Signal → Observable | No | Angular 16 |
resource() | Async data loading with signals | No | Experimental |
httpResource() | Declarative HTTP calls with signals | No | Experimental |
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:
<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 if (guest) {
<p>Welcome, guest</p>
} @else {
<p>No user found</p>
}
Key improvements:
- No
ng-templateor template reference variables needed @else ifchains — not possible with*ngIf- Type narrowing — inside
@if (user), TypeScript knowsuseris not null/undefined
@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@emptyhandles the empty-list case inline- Built-in context variables:
$index,$first,$last,$even,$odd trackis required — Angular enforces it for performance (no more silent full 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>
}
}
@let — Template Variables
New in Angular 18+, @let declares reusable template variables:
@let formattedDate = formatDate(item.date());
@let isEven = $index % 2 === 0;
<div [class.even]="isEven">{{ formattedDate }}</div>
Old vs New Syntax Comparison
| Feature | Old (*ngIf/*ngFor) | New (@if/@for) |
|---|---|---|
| Imports needed | NgIf, NgFor, NgSwitch | None — built-in |
| Empty state | Manual check with *ngIf | @empty block |
| Tracking | trackBy: method | track expr inline |
| Type narrowing | No | Yes — @if narrows types |
@else if chains | Not possible | Supported |
| Performance | Good | Better (compile-time optimized) |
Signal-Based Components
input() — Replaces @Input()
// 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:
// Transform the input value before it reaches the component
size = input('', { transform: numberAttribute });
output() — Replaces @Output()
// Before
@Output() itemDeleted = new EventEmitter<number>();
// After
itemDeleted = output<number>();
// Usage is identical: this.itemDeleted.emit(42);
model() — Two-Way Binding
// 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
// Before
@ViewChild('dialog') dialog!: ElementRef;
// After
dialog = viewChild<ElementRef>('dialog'); // Returns a signal
| Old Decorator | New Signal Query |
|---|---|
@ViewChild | viewChild() |
@ViewChildren | viewChildren() |
@ContentChild | contentChild() |
@ContentChildren | contentChildren() |
Signal queries return signals, so they compose with computed() and effect().
Real-World Example: Search Component
@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:
<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('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.
@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 element | Dropdowns, previews |
on immediate | After parent renders | Prioritized loading |
on timer(5s) | After delay | Delayed tooltips |
when expr | Custom condition true | Permission-gated content |
Triggers can be combined:
@defer (on viewport and when isLoggedIn()) {
<app-admin-panel />
}
Prefetching — load the bundle early, show it later:
@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:
@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+):
// 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 update or zone-free event |
| Scope | Entire component tree | Only affected components |
| Third-party libs | Work automatically (zone patches everything) | Must wrap in NgZone.run() or use signals |
setTimeout/fetch updates | Automatic | Wrap in NgZone.run() or convert to signals |
| Bundle size | +15KB (zone.js) | No zone.js |
| Stack traces | Cluttered with zone frames | Clean |
| SSR | Zone-based hydration | Incremental hydration |
| Core Web Vitals | Good | Better (less JS to parse) |
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 callbacks — in
NgZone.run()or convert to signals - Update tests — Call
ComponentFixture.detectChanges()manually after async operations
HTTP Calls: Before and After Zoneless
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
});
}
}
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.
// 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:
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
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
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
// 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
// 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:
ng generate @angular/core:control-flow-migration
Or manually convert *ngIf → @if, *ngFor → @for, *ngSwitch → @switch.
Step 4: Migrate Signal Queries
ng generate @angular/core:signal-queries-migration
Step 5: Enable Zoneless
// main.ts
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
provideBrowserGlobalErrorListeners(),
],
});
Remove from angular.json:
{
"polyfills": ["zone.js"] // Remove this line
}
Step 6: Test and Fix
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 NgZonenot available errors → addprovideBrowserGlobalErrorListeners()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):
@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],
template: `<nav><a routerLink="/">Home</a></nav>`
})
export class HeaderComponent {}
Import directly where needed:
@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:
| Feature | Status | Impact |
|---|---|---|
| Zoneless default | Stable | All new apps are zoneless |
| Vitest as default test runner | Stable | Faster tests, modern tooling |
| Built-in Signals DevTools formatter | Stable | Inspect signal values in browser DevTools |
| MCP Server with zoneless migration tool | Stable | AI-assisted migration planning |
Signal Forms (form()) | Experimental | Schema-based forms, no ControlValueAccessor needed |
| Angular Aria | Developer Preview | 8 headless accessible UI components |
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('') |
| 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 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 is what matters:
- Signals are production-ready —
signal(),computed(),effect(),linkedSignal(),input(),output(),model(), and signal queries are all stable - Zoneless is the default in Angular 21+ — new projects are zoneless out of the box, with no
zone.jsin the bundle - New control flow (
@if,@for,@switch,@let) is cleaner, faster, and requires zero imports @defergives you component-level lazy loading without route changes- Migration is incremental — adopt signals first, then flip the zoneless switch when ready
resource()andhttpResource()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.