Contents
What changed in Angular 20
Angular 20 was released in May 2025 and became the most significant release in years. Since then Angular 21 (November 2025) and Angular 22 (June 2026) have shipped — but everything below still applies: Signals, Zoneless and the new control flow remain the framework's foundation, and Signal Forms reached stable in v22.
Highlights:
- Signals API — fully stable (
signal,computed,effect,linkedSignal,toSignal,toObservable) - Zoneless change detection — stable starting with Angular 20.2
- New control flow (
@if,@for,@switch) — stable since v17, now recommended resource()andhttpResource()— built-in async data loading- Standalone by default — NgModules are no longer needed for new projects
- Requires TypeScript 5.8+
Signals — reactivity without Zone.js
Basics
import { signal, computed, effect } from '@angular/core';
@Component({
standalone: true,
template: `
<p>Count: {{ count() }}</p>
<p>Double: {{ double() }}</p>
<button (click)="increment()">+1</button>
`
})
export class CounterComponent {
count = signal(0); // WritableSignal<number>
double = computed(() => this.count() * 2); // Signal<number>
constructor() {
effect(() => {
// Re-runs automatically when count() changes
console.log('Count changed:', this.count());
});
}
increment() {
this.count.update(v => v + 1); // functional update
// or
this.count.set(this.count() + 1); // direct assignment
}
}
linkedSignal (Angular 19+)
linkedSignal — a signal that stays in sync with another signal:
import { signal, linkedSignal } from '@angular/core';
@Component({
standalone: true,
template: `
<select (change)="category.set($event.target.value)">
<option>Electronics</option>
<option>Books</option>
</select>
<!-- page resets when category changes -->
<span>Page: {{ page() }}</span>
<button (click)="page.set(page() + 1)">Next</button>
`
})
export class ProductListComponent {
category = signal('Electronics');
// page resets to 1 every time category changes
page = linkedSignal({
source: this.category,
computation: () => 1
});
}
Working with Observable — toSignal / toObservable
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
@Component({ standalone: true })
export class SearchComponent {
searchQuery = signal('');
// Signal → Observable (for debounce/switchMap)
results$ = toObservable(this.searchQuery).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(query => this.api.search(query))
);
// Observable → Signal (for use in the template)
results = toSignal(this.results$, { initialValue: [] });
}
Zoneless Change Detection
Zone.js — a library that patched browser APIs (setTimeout, fetch, DOM events) so Angular knew when to run change detection. It added ~100KB to the bundle and slowed down the app.
Zoneless works with Signals — Angular knows what changed, no need to patch everything indiscriminately.
// main.ts — enable Zoneless
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
// ...other providers
]
});
// Component for Zoneless — use Signals or OnPush
@Component({
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, // mandatory with Zoneless!
template: `
<h1>{{ title() }}</h1>
<ul>
@for (item of items(); track item.id) {
<li>{{ item.name }}</li>
}
</ul>
`
})
export class AppComponent {
title = signal('My App');
items = signal<Item[]>([]);
}
Without ChangeDetectionStrategy.OnPush, Zoneless doesn't work correctly. The component won't update without explicit signals from Signals or markForCheck().
New Control Flow: @if, @for, @switch
Replaces *ngIf, *ngFor, *ngSwitch. Built into the compiler, no import required.
<!-- @if — replaces *ngIf -->
@if (user(); as u) {
<p>Hello, {{ u.name }}!</p>
} @else if (loading()) {
<app-spinner />
} @else {
<p>Please log in</p>
}
<!-- @for — replaces *ngFor -->
@for (product of products(); track product.id) {
<app-product-card [product]="product" />
} @empty {
<p>No products found</p>
}
<!-- @switch — replaces *ngSwitch -->
@switch (status()) {
@case ('active') { <span class="green">Active</span> }
@case ('pending') { <span class="yellow">Pending</span> }
@default { <span class="gray">Unknown</span> }
}
@for requires track — it's not just a recommendation, it's mandatory syntax. The compiler errors out without it.
resource() and httpResource()
Built-in data loading with loading/error/data state, no third-party libraries.
resource()
import { resource, signal } from '@angular/core';
@Component({
standalone: true,
template: `
@if (productResource.isLoading()) {
<app-spinner />
} @else if (productResource.error()) {
<p>Error: {{ productResource.error() }}</p>
} @else {
<app-product [product]="productResource.value()" />
}
`
})
export class ProductDetailComponent {
productId = input.required<number>();
productResource = resource({
request: () => ({ id: this.productId() }), // reactive request
loader: async ({ request }) => {
const response = await fetch(`/api/products/${request.id}`);
if (!response.ok) throw new Error('Failed to fetch');
return response.json() as Promise<Product>;
}
});
}
resource() automatically reloads data when productId changes.
httpResource() — for HTTP requests
import { httpResource } from '@angular/common/http';
@Component({ standalone: true })
export class OrderListComponent {
userId = input.required<number>();
// HttpResource — uses HttpClient under the hood
ordersResource = httpResource<Order[]>(
() => `/api/users/${this.userId()}/orders`
);
// With query parameters
search = signal('');
searchResource = httpResource<Product[]>(() => ({
url: '/api/products',
params: { q: this.search(), limit: '20' }
}));
// Force reload
refresh() {
this.ordersResource.reload();
}
}
httpResource returns an object with:
.value()— data (Signal).isLoading()— boolean Signal.error()— Signal with the error.reload()— force reload
Standalone — no more NgModules
// Before — register in NgModule
@NgModule({
declarations: [ProductCardComponent],
imports: [CommonModule, RouterModule],
exports: [ProductCardComponent]
})
export class ProductsModule {}
// Now — a standalone component declares its own dependencies
@Component({
standalone: true,
selector: 'app-product-card',
imports: [CurrencyPipe, RouterLink], // only what you need
template: `
<a [routerLink]="['/products', product().id]">
<h3>{{ product().name }}</h3>
<p>{{ product().price | currency:'RUB' }}</p>
</a>
`
})
export class ProductCardComponent {
product = input.required<Product>();
}
input(), output(), model() — the new component API
// Old syntax
@Input() productId!: number;
@Output() selected = new EventEmitter<Product>();
@Input({ required: true }) label!: string;
// New syntax (Angular 17.1+, Signal-based)
@Component({ standalone: true })
export class ProductComponent {
// inputs
productId = input.required<number>(); // required
category = input<string>('all'); // with default value
// output
selected = output<Product>();
// two-way binding
value = model<string>('');
// computed from input
productUrl = computed(() => `/products/${this.productId()}`);
}
// Usage in the parent template
// <app-product [productId]="42" (selected)="onSelect($event)" />
// <app-input [(value)]="searchQuery" />
Example: a complete component with Signals
import { Component, signal, computed, inject } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
import { CurrencyPipe } from '@angular/common';
import { ChangeDetectionStrategy } from '@angular/core';
interface Product {
id: number;
name: string;
price: number;
category: string;
}
@Component({
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule, CurrencyPipe],
template: `
<input [(ngModel)]="search" placeholder="Search..." />
@if (productsResource.isLoading()) {
<p>Loading...</p>
} @else if (productsResource.error()) {
<p>Failed to load products</p>
<button (click)="productsResource.reload()">Retry</button>
} @else {
<p>Found: {{ filteredProducts().length }}</p>
@for (p of filteredProducts(); track p.id) {
<div>
<strong>{{ p.name }}</strong>
<span>{{ p.price | currency:'RUB' }}</span>
</div>
} @empty {
<p>No products match your search</p>
}
}
`
})
export class ProductListComponent {
search = signal('');
productsResource = httpResource<Product[]>('/api/products');
filteredProducts = computed(() => {
const query = this.search().toLowerCase();
const products = this.productsResource.value() ?? [];
return query
? products.filter(p => p.name.toLowerCase().includes(query))
: products;
});
}
Migrating from Zone.js to Signals
For Signals-based state management there's a separate article — Signal Store instead of NgRx. And Claude Code with the /ng-upgrade skill helps automate the migration routine.
A gradual path:
- Upgrade to Angular 20, add TypeScript 5.8+
- Convert components to
ChangeDetectionStrategy.OnPush - Replace
@Input()/@Output()withinput()/output() - Replace
BehaviorSubjectwithsignal() - Replace
*ngIf/*ngForwith@if/@for - Add
provideZonelessChangeDetection()and removezone.jsfrompolyfills
You can do it incrementally — Zone.js and Signals work together.
💡
ng update @angular/core @angular/cli— the official migration. For each major version Angular ships schematics that automatically convert old syntax. Runng updateto see available migrations.
Discussion
No comments yet. Be the first.