Les Angular signals et le mode zoneless sont le plus gros changement d'Angular depuis Ivy. Ils remplacent Zone.js par une réactivité fine et explicite qui rend la change detection plus rapide et plus prévisible. Voici ce que ça change concrètement, côté performance, dans une application réelle, avec le code de migration.
Note de version (juin 2026). Les signals (
signal/computed/effect), les signal inputs (input()) etmodel()sont stables depuis Angular 19.linkedSignal()est stable depuis Angular 20, tout comme la change detection zoneless (provideZonelessChangeDetection, stable en 20.2). La resource API (resource(),httpResource()) reste en developer preview : utile, mais à ne pas considérer comme figée. Le zoneless devient le mode par défaut à partir d'Angular 21.
Le problème que Zone.js posait
Depuis ses débuts, Angular s'appuyait sur Zone.js : une bibliothèque qui patche (monkey-patch) toutes les API asynchrones du navigateur (setTimeout, setInterval, addEventListener, Promise, fetch…) pour savoir quand relancer la change detection. Pratique : on n'avait jamais à dire à Angular « rafraîchis-toi ». Mais coûteux à deux titres.
D'abord le coût mémoire et bundle : Zone.js pèse environ 13 ko gzippé (~100 ko non compressé) ajoutés aux polyfills. Ensuite, et surtout, le coût d'exécution : par défaut, n'importe quelle tâche asynchrone déclenche une passe de détection qui revérifie tout l'arbre de composants depuis la racine, même les branches qui n'ont pas bougé. Un setInterval perdu dans un coin, un mousemove, une lib tierce qui poll en arrière-plan : tout cela fait travailler Angular pour rien.
ChangeDetectionStrategy.OnPush atténuait le problème en élaguant des sous-arbres, mais on restait dans un modèle où Angular vérifie « au cas où ». Les signals inversent la logique : un composant ne se met à jour que si une donnée qu'il lit réellement dans son template a changé.
Les Angular signals en 3 primitives
import { signal, computed, effect } from '@angular/core';
// 1. signal : un état réactif, source de vérité modifiable
const quantity = signal(1);
const price = signal(29.9);
// 2. computed : une valeur dérivée, paresseuse et mémoïsée
const total = computed(() => quantity() * price());
// 3. effect : un effet de bord re-exécuté quand une dépendance lue change
effect(() => console.log(`Total : ${total()} €`));
quantity.set(3); // écriture directe
quantity.update(q => q + 1); // écriture dérivée de l'ancienne valeur
On lit un signal en l'appelant (quantity()). C'est cette lecture qui crée la dépendance : un computed ou un effect ne « voit » que les signals qu'il appelle effectivement à l'exécution.
| Primitive | Rôle | Écriture | Recalcul |
|---|---|---|---|
signal() |
État source modifiable | set() / update() |
- |
computed() |
Valeur dérivée mémoïsée | lecture seule | Paresseux, à la lecture |
effect() |
Effet de bord réactif | - | Quand une dépendance lue change |
À retenir : un
computedest paresseux et mémoïsé : il ne recalcule que si une de ses dépendances lues a changé, et seulement quand on le relit. Tant que personne ne lit la valeur, aucun calcul n'a lieu. C'est là que se gagne la performance, là où un getter, lui, recalcule à chaque cycle de détection.
Un mot sur effect : il sert aux effets de bord (log, synchronisation avec une API impérative, localStorage…), pas à dériver de l'état (pour ça, c'est computed). Et il s'exécute dans un contexte d'injection, donc on l'appelle typiquement dans le constructeur ou via le champ d'une classe.
Signal inputs : des @Input() réactifs
Les input() remplacent le décorateur @Input() et deviennent des signals que l'on peut composer directement dans des computed. Stables depuis Angular 19.
import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core';
@Component({
selector: 'app-price-tag',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<span>{{ finalPrice() }} €</span>`,
})
export class PriceTagComponent {
// input.required : pas de valeur par défaut, vérifié au typage
price = input.required<number>();
// input : valeur par défaut, transform optionnel
discount = input(0, { transform: (v: number) => Math.min(Math.max(v, 0), 100) });
finalPrice = computed(() => this.price() * (1 - this.discount() / 100));
}
Quand le composant a besoin d'écrire dans la valeur et de la renvoyer au parent (champ de formulaire, slider, date picker…), on utilise model() plutôt que input() : il crée à la fois un input et un output xxxChange, ce qui rend le [(banana-in-a-box)] possible.
import { Component, model } from '@angular/core';
@Component({
selector: 'app-rating',
template: `<button (click)="value.set(value() + 1)">{{ value() }}</button>`,
})
export class RatingComponent {
// two-way : le parent fait <app-rating [(value)]="note" />
value = model(0);
}
| API | Direction | Lecture/écriture | Cas d'usage |
|---|---|---|---|
input() |
parent → enfant | lecture seule | Donnée descendante classique |
input.required<T>() |
parent → enfant | lecture seule | Donnée obligatoire (typée) |
model() |
parent ↔ enfant | lecture et écriture | Composants de formulaire, [(value)] |
linkedSignal et resource API
linkedSignal() (stable en Angular 20) résout un cas concret : un état modifiable mais qui doit se réinitialiser quand une source change. L'exemple type est un select dont l'option choisie doit retomber sur la première quand la liste d'options change.
import { signal, linkedSignal } from '@angular/core';
const options = signal(['S', 'M', 'L']);
// writable comme un signal, mais réinitialisé quand options() change
const choice = linkedSignal(() => options()[0]);
choice.set('L'); // l'utilisateur choisit
options.set(['XS', 'S']); // la source change → choice repasse à 'XS'
La resource API (resource(), et httpResource() pour le HTTP) relie un signal de paramètres à une charge asynchrone et expose value(), status(), error(). Pratique pour du data-fetching réactif, mais en developer preview : l'API peut encore bouger, à isoler derrière une couche service si vous l'adoptez tôt.
import { resource, signal } from '@angular/core';
const userId = signal(1);
// se relance automatiquement quand userId() change (API encore expérimentale)
const user = resource({
params: () => ({ id: userId() }),
loader: ({ params }) => fetch(`/api/users/${params.id}`).then(r => r.json()),
});
Le mode zoneless
À partir d'Angular 20 (stable en 20.2), on peut supprimer complètement Zone.js. La détection n'est plus déclenchée « à tout hasard » mais uniquement par des signaux explicites : écriture d'un signal lu dans un template, événements de template ((click)), async pipe qui émet, markForCheck(), ou fin d'un set/update.
// app.config.ts
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [provideZonelessChangeDetection()],
};
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
bootstrapApplication(AppComponent, appConfig);
Il reste à retirer zone.js des polyfills dans angular.json (et de tout import 'zone.js'), sans quoi on garde le coût du polyfill pour rien.
| Critère | Avec Zone.js | Zoneless |
|---|---|---|
| Déclencheur de la détection | Toute tâche async | Signals, événements, async pipe, markForCheck |
| Composants revérifiés | Tout l'arbre (sauf élagage OnPush) |
Seulement le chemin des données qui ont changé |
| Polyfill bundle | + Zone.js (~13 ko gzip) | Supprimé |
| Prévisibilité | Implicite | Explicite |
| Statut | Historique | Stable (20.2), défaut en v21 |
À retenir : le zoneless ne « rend pas magiquement l'app rapide ». Il supprime le déclencheur global et vous rend responsable des notifications. Si votre état repose déjà sur des signals et
OnPush, la bascule est quasi transparente. Sinon, ce sont les mises à jour faites hors signal/événement (mutation directe d'un objet, callback de lib tierce) qui cesseront de rafraîchir l'UI, d'où l'ordre de migration ci-dessous.
Migrer sans tout réécrire
Bonne nouvelle : la migration est incrémentale. On introduit les signals composant par composant tout en gardant Zone.js, puis on bascule en zoneless en dernier, une fois l'app prête. L'ordre compte : chaque étape est utile en soi et réduit le risque de la suivante.
- Passez les composants en
OnPush. Prérequis sain, déjà bénéfique sous Zone.js, et qui révèle les composants qui dépendaient de la détection globale. - Convertissez l'état local en signals (
signal+set/update). - Remplacez les valeurs dérivées par des
computedplutôt que des getters appelés depuis le template. - Migrez les
@Input()versinput()(et@Output()versoutput(), les queries vers les versions signal) quand vous touchez un composant. Les schématiques officielles automatisent une grande partie :ng generate @angular/core:signal-input-migration, puis:output-migrationet:signal-queries-migration. - Activez le zoneless (
provideZonelessChangeDetection) une fois l'app majoritairement signal-based, et retirezzone.jsdes polyfills.
// Avant : getter recalculé à CHAQUE cycle de change detection
get total() {
return this.items.reduce((sum, i) => sum + i.price, 0);
}
// Après : computed, recalculé seulement quand items() change, et seulement à la lecture
items = signal<Item[]>([]);
total = computed(() => this.items().reduce((sum, i) => sum + i.price, 0));
Pendant la phase de transition, gardez un œil sur les mutations en place (this.items.push(...)) : avec un signal, il faut passer par update(items => [...items, nouvel]) pour notifier les dépendances. C'est le piège n°1 quand on migre.
Ce que ça change concrètement
- Détection ciblée : fini la revérification de l'arbre entier à chaque clic ou timer ; Angular ne recalcule que le chemin des signals lus qui ont changé.
- Bundle plus léger : ~13 ko gzip de Zone.js retirés des polyfills.
- Code plus lisible : les dépendances de données sont explicites, on voit dans le
computedexactement ce dont dépend une valeur. - Debug plus simple : on sait pourquoi un composant se met à jour, au lieu de chasser un déclencheur global invisible.
Le gain réel dépend de l'app : sur des écrans à forte fréquence d'événements (tableaux, dashboards temps réel, formulaires riches), la détection ciblée fait la différence ; sur une page statique, l'essentiel du bénéfice est la lisibilité et les ~13 ko en moins.
Pour aller plus loin
Dans la continuité de ces optimisations Angular, voir aussi mon retour d'expérience : Comment j'ai optimisé les performances d'une application Angular de 40 %.
Vous voulez moderniser une application Angular, passer aux signals ou amorcer une bascule zoneless sans casser la prod ? Discutons-en.