10 juin 2026 · Tommy Bordas

Angular signals et zoneless, ce qui change vraiment dans une appli réelle

angularsignalszonelessperformancechange-detection

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()) et model() 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 computed est 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.

  1. 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.
  2. Convertissez l'état local en signals (signal + set/update).
  3. Remplacez les valeurs dérivées par des computed plutôt que des getters appelés depuis le template.
  4. Migrez les @Input() vers input() (et @Output() vers output(), 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-migration et :signal-queries-migration.
  5. Activez le zoneless (provideZonelessChangeDetection) une fois l'app majoritairement signal-based, et retirez zone.js des 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 computed exactement 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.