📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~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() (comme output())
  • 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() xChangemodel()
    • @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() ou NgZone.runOutsideAngular()
  • Les tests n'utilisent pas fakeAsync / tick avec Zone.js

Pendant la migration

  • Activer provideZonelessChangeDetection() dans main.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.js des polyfills dans angular.json
  • Supprimer zone.js/testing des 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.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

EasyAngularKit

Formation complète pour maîtriser Angular et développer des applications web modernes.

Navigation

Contact

Légal

© 2026 Easy Angular Kit. Tous droits réservés.