~5 min de lecture
De Zone.js à Zoneless : le guide complet de migration Angular
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 ?".
Ce guide couvre tout : le contexte historique, les concepts clés, la stratégie de migration progressive, et les pièges à éviter.
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.
// 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 :
# Analyse et plan de migration OnPush/zoneless
ng generate @angular/core:onpush-zoneless-migration
# Migration @Input() → input()
ng generate @angular/core:signal-input-migration
# Migration @Output() → output()
ng generate @angular/core:signal-output-migration
# Migration @Input() + @Output() xxxChange → model()
ng generate @angular/core:model-input-migration
# Migration @ViewChild/@ContentChild → viewChild()/contentChild()
ng generate @angular/core:signal-queries-migration
Ces schematics analysent ton code et effectuent les transformations automatiquement.
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.