~6 min de lecture
De Zone.js à Zoneless : le guide complet de migration Angular
TL;DR
En zoneless, Angular ne patche plus les API asynchrones du navigateur : ce n'est plus lui qui devine quand rafraîchir
l'UI, c'est ton code qui le notifie, via les signals lus dans le template, l'AsyncPipe, les événements du template ou
markForCheck(). Sur une application existante, la migration se joue en 4 phases : passer les composants en OnPush,
convertir les propriétés bindées en signals, activer provideZonelessChangeDetection() en gardant encore Zone.js dans
le bundle, puis seulement retirer le polyfill. Tant qu'un binding est mis à jour depuis un callback non notifié
(setTimeout, WebSocket, subscribe), l'écran reste figé : c'est précisément ce que la phase 3 sert à débusquer.
Angular 21 marque un tournant historique : le mode zoneless est désormais activé par défaut pour les nouveaux projets. Si tu travailles sur une application existante, la question n'est plus "faut-il migrer ?" mais "comment migrer proprement ?".
Zone.js : comprendre l'héritage
Le problème que Zone.js résolvait
Quand Angular 2 est sorti en 2016, l'équipe avait un défi : comment savoir quand mettre à jour l'interface utilisateur après une opération asynchrone ?
La solution : Zone.js, une librairie qui "patche" les APIs asynchrones du navigateur (setTimeout, Promise,
addEventListener, fetch...) pour intercepter chaque opération et déclencher automatiquement la détection de
changements.
// Avec Zone.js, ce code "magique" fonctionne
@Component({
template: `<p>{{ message }}</p>`
})
export class OldComponent {
message = 'Chargement...';
ngOnInit() {
setTimeout(() => {
this.message = 'Terminé !'; // L'UI se met à jour automatiquement
}, 1000);
}
}
Cette magie a un coût.
Les problèmes de Zone.js
| Problème | Impact |
|---|---|
| Monkey-patching global | Zone.js modifie les APIs natives du navigateur, ce qui peut créer des conflits avec d'autres librairies |
| Détections inutiles | Chaque callback async déclenche une vérification, même sans changement de données |
| Bundle size | ~13-15 KB gzippés ajoutés à chaque application |
| Debugging difficile | Les stack traces sont polluées par les wrappers Zone.js |
| Compatibilité | Certaines APIs modernes (comme async/await dans certains contextes) nécessitent un traitement spécial |
Zoneless : le nouveau modèle
Le principe
En mode zoneless, Angular ne patche plus les APIs du navigateur. C'est toi qui indiques explicitement quand l'UI doit être mise à jour, via :
- Les Signals (lecture dans le template)
- L'AsyncPipe (pour les Observables)
ChangeDetectorRef.markForCheck()- Les événements du template (click, input...)
ComponentRef.setInput()
// En zoneless, tu dois être explicite
@Component({
template: `<p>{{ message() }}</p>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ModernComponent {
message = signal('Chargement...');
constructor() {
setTimeout(() => {
this.message.set('Terminé !'); // Le signal notifie Angular
}, 1000);
}
}
Timeline de l'évolution
| Version | Date | État du zoneless |
|---|---|---|
| Angular 17 | Nov 2023 | Expérimental (developer preview) |
| Angular 18 | Mai 2024 | Expérimental amélioré |
| Angular 19 | Nov 2024 | Expérimental mature |
| Angular 20.2 | Sept 2025 | Stable |
| Angular 21 | Nov 2025 | Par défaut pour les nouveaux projets |
Stratégie de migration progressive
La migration ne doit pas être un big bang. Voici une approche en 4 phases.
Phase 1 : Préparer le terrain avec OnPush
Avant même de penser au zoneless, migre tes composants vers ChangeDetectionStrategy.OnPush. C'est une étape
préparatoire qui te forcera à expliciter tes sources de changement. Si tu montes en v22, cette phase compte davantage, pas moins : OnPush y devient la stratégie par défaut, et la migration change-detection-eager lancée par ng update pose Eager sur tes composants restés en Default, explicite ou implicite. Eager est l'alias de l'ancien Default (ce qu'il vaut en v22), et ceux que tu as déjà passés en OnPush sont épargnés. Chaque composant migré maintenant est un Eager en moins à retirer ensuite.
// Avant : décorateur legacy
@Component({
selector: 'app-user-card',
template: `
<div class="card">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>
`
})
export class UserCardComponent {
@Input() user!: User; // Décorateur legacy
}
// Après : signal input + OnPush
@Component({
selector: 'app-user-card',
template: `
<div class="card">
<h2>{{ user().name }}</h2>
<p>{{ user().email }}</p>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserCardComponent {
user = input.required<User>(); // Signal input
}
Migrations automatiques disponibles :
# Migration @Input() → input()
ng generate @angular/core:signal-input-migration
# Migration @Output() → output()
ng generate @angular/core:output-migration
# Migration @ViewChild/@ContentChild → viewChild()/contentChild()
ng generate @angular/core:signal-queries-migration
# Les trois d'un coup
ng generate @angular/core:signals
Ces schematics analysent ton code et effectuent les transformations automatiquement. Ni le passage en OnPush ni la conversion d'un couple @Input() / @Output() xxxChange en model() n'ont la leur : ces deux chantiers-là restent manuels. Autre réserve : ils ne lisent que les tsConfig déclarés par les cibles de ton angular.json. Sans tsConfig sur ta cible test (le cas des applications générées à partir de la v21), ils migrent ton code applicatif et laissent tes specs derrière. Le détail des cibles concernées est dans Les 15 migrations Angular que tu n'as jamais lancées.
Phase 2 : Adopter les Signals
Convertis progressivement tes propriétés en signals, en commençant par les composants feuilles (sans enfants).
// Avant : propriété classique
@Component({
template: `
<div>
<p>Compteur : {{ count }}</p>
<button (click)="increment()">+1</button>
</div>
`
})
export class CounterComponent {
count = 0;
increment() {
this.count++;
}
}
// Après : signal
@Component({
template: `
<div>
<p>Compteur : {{ count() }}</p>
<button (click)="increment()">+1</button>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterComponent {
count = signal(0);
increment() {
this.count.update(c => c + 1);
}
}
Pour les Observables, utilise toSignal() :
import {toSignal} from '@angular/core/rxjs-interop';
@Component({
template: `
@if (user(); as u) {
<p>{{ u.name }}</p>
} @else {
<p>Chargement...</p>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserComponent {
private userService = inject(UserService);
// L'Observable est converti en Signal
user = toSignal(this.userService.getCurrentUser());
}
Exemple complet d'un composant moderne (Angular 21) :
import {
Component, ChangeDetectionStrategy,
input, output, viewChild, signal, computed, inject
} from '@angular/core';
import {toSignal} from '@angular/core/rxjs-interop';
@Component({
selector: 'app-product-card',
template: `
<article class="card" #cardElement>
<h2>{{ product().name }}</h2>
<p class="price">{{ formattedPrice() }}</p>
@if (product().stock > 0) {
<button (click)="addToCart.emit(product())">
Ajouter au panier
</button>
} @else {
<span class="out-of-stock">Rupture de stock</span>
}
</article>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductCardComponent {
// Signal inputs (remplacent @Input())
product = input.required<Product>();
currency = input<string>('EUR');
// Signal output (remplace @Output())
addToCart = output<Product>();
// Signal query (remplace @ViewChild())
cardElement = viewChild.required<ElementRef>('cardElement');
// Computed signal dérivé des inputs
formattedPrice = computed(() =>
new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: this.currency()
}).format(this.product().price)
);
}
Two-way binding avec model() :
Le pattern classique @Input() value + @Output() valueChange est remplacé par model() :
// ❌ Ancien pattern two-way binding
@Component({
selector: 'app-rating',
template: `
@for (star of stars; track star) {
<button
[class.filled]="star <= value"
(click)="setValue(star)">
★
</button>
}
`
})
export class RatingComponent {
@Input() value = 0;
@Output() valueChange = new EventEmitter<number>();
stars = [1, 2, 3, 4, 5];
setValue(star: number) {
this.value = star;
this.valueChange.emit(star);
}
}
// Utilisation : <app-rating [(value)]="product.rating" />
// ✅ Nouveau pattern avec model()
import {Component, model, ChangeDetectionStrategy} from '@angular/core';
@Component({
selector: 'app-rating',
template: `
@for (star of stars; track star) {
<button
[class.filled]="star <= value()"
(click)="value.set(star)">
★
</button>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RatingComponent {
// model() = signal bidirectionnel (input + output combinés)
value = model(0);
stars = [1, 2, 3, 4, 5];
}
// Utilisation identique : <app-rating [(value)]="product.rating" />
model() est un signal writable qui :
- Reçoit la valeur du parent (comme
input()) - Notifie le parent à chaque
.set()ou.update()(commeoutput()) - Fonctionne avec la syntaxe banana-in-a-box
[()]
// Variante required
value = model.required<number>();
// Avec alias
value = model(0, {alias: 'rating'});
// → <app-rating [(rating)]="product.rating" />
Phase 3 : Activer le mode zoneless (avec Zone.js encore présent)
Active le provider zoneless tout en gardant Zone.js dans le bundle. Cela te permet de tester le comportement sans risque.
// main.ts
import {bootstrapApplication} from '@angular/platform-browser';
import {provideZonelessChangeDetection} from '@angular/core';
import {AppComponent} from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection()
]
});
Outil de debug :
// Pour détecter les mises à jour non-notifiées
import {provideCheckNoChangesConfig} from '@angular/core';
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
provideCheckNoChangesConfig({
exhaustive: true,
interval: 1000 // Vérifie toutes les secondes
})
]
});
Ce provider lance périodiquement une vérification et lève une erreur si un binding a changé sans notification explicite à Angular.
Phase 4 : Supprimer Zone.js
Une fois tous les problèmes résolus, supprime Zone.js :
1. Retirer du build (angular.json) :
{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"polyfills": [
// Supprimer "zone.js"
]
}
},
"test": {
"options": {
"polyfills": [
// Supprimer "zone.js" et "zone.js/testing"
]
}
}
}
}
}
}
2. Désinstaller le package :
npm uninstall zone.js
Patterns de migration courants
setTimeout / setInterval
// ❌ Ne fonctionne plus en zoneless
export class TimerComponent {
seconds = 0;
ngOnInit() {
setInterval(() => {
this.seconds++; // L'UI ne se met pas à jour
}, 1000);
}
}
// ✅ Solution 1 : Signal
export class TimerComponent {
seconds = signal(0);
ngOnInit() {
setInterval(() => {
this.seconds.update(s => s + 1);
}, 1000);
}
}
// ✅ Solution 2 : RxJS + toSignal
export class TimerComponent {
seconds = toSignal(interval(1000), {initialValue: 0});
}
Appels HTTP
// ❌ Pattern legacy
export class DataComponent {
data: Data | null = null;
constructor(private http: HttpClient) {
}
ngOnInit() {
this.http.get<Data>('/api/data').subscribe(d => {
this.data = d; // Ne déclenche pas de mise à jour en zoneless
});
}
}
// ✅ Solution 1 : Signal manuel
export class DataComponent {
private http = inject(HttpClient);
data = signal<Data | null>(null);
ngOnInit() {
this.http.get<Data>('/api/data').subscribe(d => {
this.data.set(d);
});
}
}
// ✅ Solution 2 : toSignal
export class DataComponent {
private http = inject(HttpClient);
data = toSignal(this.http.get<Data>('/api/data'));
}
// ✅ Solution 3 : resource (Angular 19+)
export class DataComponent {
private http = inject(HttpClient);
dataResource = resource({
loader: () => firstValueFrom(this.http.get<Data>('/api/data'))
});
// dataResource.value() pour la valeur
// dataResource.isLoading() pour l'état de chargement
}
// ✅ Solution 4 : AsyncPipe (toujours valide)
@Component({
template: `
@if (data$ | async; as data) {
<p>{{ data.name }}</p>
}
`
})
export class DataComponent {
data$ = inject(HttpClient).get<Data>('/api/data');
}
WebSockets et événements externes
// ❌ Les événements WebSocket ne déclenchent pas de mise à jour
export class ChatComponent {
messages: string[] = [];
constructor() {
const socket = new WebSocket('wss://api.example.com/chat');
socket.onmessage = (event) => {
this.messages.push(event.data); // UI non mise à jour
};
}
}
// ✅ Utiliser un signal
export class ChatComponent {
messages = signal<string[]>([]);
constructor() {
const socket = new WebSocket('wss://api.example.com/chat');
socket.onmessage = (event) => {
this.messages.update(msgs => [...msgs, event.data]);
};
}
}
Remplacer NgZone
// ❌ Ancien pattern avec NgZone
export class LegacyComponent {
value = '';
constructor(private ngZone: NgZone) {
someExternalLibrary.onUpdate((newValue) => {
this.ngZone.run(() => {
this.value = newValue;
});
});
}
}
// ✅ Nouveau pattern avec signal
export class ModernComponent {
value = signal('');
constructor() {
someExternalLibrary.onUpdate((newValue) => {
this.value.set(newValue); // Pas besoin de NgZone
});
}
}
// ✅ Alternative : markForCheck si tu ne peux pas utiliser de signal
export class ModernComponent {
value = '';
private cdr = inject(ChangeDetectorRef);
constructor() {
someExternalLibrary.onUpdate((newValue) => {
this.value = newValue;
this.cdr.markForCheck();
});
}
}
Tests en mode zoneless
Configuration TestBed
import {TestBed} from '@angular/core/testing';
import {provideZonelessChangeDetection} from '@angular/core';
describe('MyComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MyComponent],
providers: [provideZonelessChangeDetection()]
}).compileComponents();
});
it('should update display', async () => {
const fixture = TestBed.createComponent(MyComponent);
// ✅ Préférer whenStable() à detectChanges()
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('Expected');
});
});
Pourquoi whenStable() plutôt que detectChanges() ?
fixture.detectChanges() force une détection de changements, ce qui peut masquer des problèmes de notification.
fixture.whenStable() attend qu'Angular ait naturellement effectué ses mises à jour, reproduisant le comportement de
production.
// ❌ Peut masquer des problèmes
it('test with detectChanges', () => {
const fixture = TestBed.createComponent(MyComponent);
fixture.componentInstance.someValue = 'new'; // Pas de notification !
fixture.detectChanges(); // Force la mise à jour quand même
expect(...).toBe(...); // Le test passe, mais le code est buggé
});
// ✅ Révèle les problèmes
it('test with whenStable', async () => {
const fixture = TestBed.createComponent(MyComponent);
fixture.componentInstance.someValue = 'new'; // Pas de notification
await fixture.whenStable(); // Angular ne détecte pas le changement
// Le test échoue → tu sais qu'il faut corriger le composant
});
SSR et PendingTasks
En Server-Side Rendering, Angular doit savoir quand l'application est prête à être sérialisée. Sans Zone.js, utilise
PendingTasks :
import {inject} from '@angular/core';
import {PendingTasks} from '@angular/core';
export class DataComponent {
private pendingTasks = inject(PendingTasks);
private http = inject(HttpClient);
data = signal<Data | null>(null);
ngOnInit() {
// Méthode 1 : run() pour les Promises
this.pendingTasks.run(async () => {
const response = await firstValueFrom(this.http.get<Data>('/api'));
this.data.set(response);
});
}
}
Pour les Observables, utilise pendingUntilEvent() :
import {pendingUntilEvent} from '@angular/core/rxjs-interop';
export class DataComponent {
data$ = inject(HttpClient).get<Data>('/api').pipe(
pendingUntilEvent() // Bloque la sérialisation SSR jusqu'à émission
);
}
Checklist de migration
Avant de commencer
- Tous les composants utilisent
ChangeDetectionStrategy.OnPush - Migration des décorateurs vers les fonctions signal :
-
@Input()→input()/input.required() -
@Output()→output() -
@Input() x+@Output() xChange→model() -
@ViewChild()→viewChild()/viewChild.required() -
@ContentChild()→contentChild()
-
- Les propriétés bindées dans les templates sont des signals ou passent par
AsyncPipe - Aucune utilisation de
NgZone.run()ouNgZone.runOutsideAngular() - Les tests n'utilisent pas
fakeAsync/tickavec Zone.js
Pendant la migration
- Activer
provideZonelessChangeDetection()dansmain.ts - Activer
provideCheckNoChangesConfig({ exhaustive: true })en dev - Corriger les erreurs
ExpressionChangedAfterItHasBeenCheckedError - Vérifier les intégrations avec des librairies tierces
Après la migration
- Retirer
zone.jsdes polyfills dansangular.json - Supprimer
zone.js/testingdes polyfills de test - Désinstaller le package
zone.js - Vérifier la réduction du bundle size
Bénéfices mesurables
| Métrique | Avec Zone.js | Sans Zone.js | Gain |
|---|---|---|---|
| Bundle size | +13-15 KB gzip | 0 KB | ~15 KB |
| Détections de changements (app complexe) | ~100-500/s idle | ~0/s idle | Drastique |
| First Contentful Paint | Baseline | -5 à -15% | Variable |
| Stack traces | Polluées | Propres | DX++ |
Conclusion
La migration vers zoneless n'est pas qu'une optimisation technique : c'est un changement de paradigme. Tu passes d'un modèle où Angular "devine" quand mettre à jour l'UI à un modèle où tu le lui dis explicitement.
Pour les nouveaux projets : démarre directement en zoneless avec Angular 21+.
Pour les projets existants : migre progressivement en suivant les 4 phases. Ne précipite pas la suppression de Zone.js ; assure-toi d'abord que tous tes composants sont compatibles.
Les Signals sont la clé de cette transition. Plus tu les adoptes tôt, plus la migration sera simple.