Un signal est une boîte qui contient une valeur et prévient automatiquement tous ceux qui l’ont lue quand elle change. Angular fait le suivi : quand le template, un computed() ou un effect() lit count(), il en dépend. Quand count change, seul ce qui en dépend est mis à jour — pas de Zone.js, pas de vérification de tout l’arbre de composants.
Depuis la v21, les nouvelles applications sont zoneless, et depuis la v22 les composants sont OnPush par défaut. Les Signals ne sont donc plus « une option moderne » — c’est le mécanisme principal par lequel Angular sait quoi redessiner. Si vous gardez l’état dans des champs ordinaires et que vous le modifiez depuis un setTimeout ou un subscribe, l’UI peut cesser de se mettre à jour.
Les trois opérations de base
On crée avec signal(initialValue), on lit en appelant le signal comme une fonction, et on écrit avec set() ou update(). L’état dérivé se crée avec computed().
import { computed, signal, Signal, WritableSignal } from '@angular/core';
const name: WritableSignal<string> = signal('Ana');
name(); // lecture → 'Ana'
name.set('Maria'); // écriture directe
name.update((n) => n.toUpperCase()); // écriture à partir de l’ancienne valeur
const greeting: Signal<string> = computed(() => `Bonjour, ${name()} !`);
greeting(); // 'Bonjour, MARIA !'
// greeting.set(...) ✗ un computed est en lecture seule
const readonlyName: Signal<string> = name.asReadonly(); // exposé sans accès en écrituredouble()= 0parity()= paircomputedrecalculé 1 fois (uniquement quand count a changé)
Ce qu’il faut retenir de computed()
- Lazy : il n’est pas calculé tant que personne ne le lit.
- Mémoïsé : si les dépendances n’ont pas changé, il renvoie la valeur en cache — peu importe combien de fois vous le lisez.
- Dépendances dynamiques : il ne suit que les signals réellement lus lors de la dernière exécution. Dans un
cond() ? a() : b(), quandcondest vrai, les changements debsont ignorés. - Pur : pas d’effets de bord (pas de HTTP, pas de
set()sur d’autres signals, pas de DOM).
La règle d’or : état dérivé = computed()
Toute valeur qui peut être calculée à partir d’un autre état ne se stocke pas séparément. Le total d’un panier, la liste filtrée, « le formulaire est-il valide », « l’utilisateur a-t-il les droits d’admin » — tout cela, ce sont des computed(). Une seule source de vérité, zéro synchronisation manuelle.
interface CartItem {
id: number;
name: string;
price: number;
qty: number;
}
export class Cart {
protected readonly items = signal<CartItem[]>([]);
protected readonly filter = signal('');
// ✓ Tout ce qui peut être calculé à partir d’autre chose = computed()
protected readonly visible = computed(() =>
this.items().filter((i) => i.name.toLowerCase().includes(this.filter().toLowerCase())),
);
protected readonly subtotal = computed(() =>
this.items().reduce((sum, i) => sum + i.price * i.qty, 0),
);
protected readonly vat = computed(() => Math.round(this.subtotal() * 0.19));
protected readonly total = computed(() => this.subtotal() + this.vat());
protected readonly freeShipping = computed(() => this.subtotal() >= 1000);
protected changeQty(id: number, delta: number): void {
this.items.update((items) =>
items.map((i) => (i.id === id ? { ...i, qty: Math.max(0, i.qty + delta) } : i)),
);
}
}| Produit | Prix | Quantité | Total |
|---|---|---|---|
| Clavier mécanique | 450 RON | 1 | 450 RON |
| Souris ergonomique | 220 RON | 0 | 0 RON |
| Écran 27" | 1300 RON | 0 | 0 RON |
| Hub USB-C | 160 RON | 2 | 320 RON |
// ✓ Une seule source de vérité, le reste est dérivé
export class Cart {
readonly items = signal<CartItem[]>([]);
readonly total = computed(() => this.items().reduce((s, i) => s + i.price, 0));
add(item: CartItem) {
this.items.update((list) => [...list, item]);
}
remove(id: number) {
this.items.update((list) => list.filter((i) => i.id !== id));
}
}// ✗ État dérivé STOCKÉ et synchronisé à la main
export class Cart {
items = signal<CartItem[]>([]);
total = signal(0);
add(item: CartItem) {
this.items.update((list) => [...list, item]);
this.total.set(this.total() + item.price); // facile à oublier, facile à désynchroniser
}
remove(id: number) {
this.items.update((list) => list.filter((i) => i.id !== id));
// bug : on a oublié de mettre à jour total
}
}
// ✗ Tout aussi mauvais : un effect qui « copie » une valeur dans un autre signal
effect(() => this.total.set(this.items().reduce((s, i) => s + i.price, 0)));Immutabilité : pourquoi push() ne fonctionne pas
Un signal décide s’il a changé en comparant l’ancienne et la nouvelle valeur avec Object.is, c’est-à-dire par référence. Si vous modifiez un tableau ou un objet sur place, la référence reste la même et personne n’est prévenu. Le template peut se redessiner par hasard (par exemple parce que vous avez cliqué sur quelque chose dans le composant), mais les computed() gardent l’ancienne valeur. Résultat : une UI partiellement à jour — le bug le plus difficile à trouver.
tags())count = computed(() => tags().length)const tags = signal(['a', 'b']);
// ✗ Mutation : la même référence de tableau → Angular n’apprend RIEN
tags().push('c');
tags.set(tags()); // ça n’aide toujours pas : Object.is(old, new) === true
// ✓ Toujours une nouvelle référence
tags.update((t) => [...t, 'c']); // ajout
tags.update((t) => t.filter((x) => x !== 'a')); // suppression
tags.update((t) => t.map((x) => (x === 'b' ? 'B' : x))); // modification
const user = signal({ name: 'Ana', address: { city: 'Cluj' } });
// ✗ user().address.city = 'Iași';
// ✓
user.update((u) => ({ ...u, address: { ...u.address, city: 'Iași' } }));Égalité personnalisée avec equal
Parfois, vous recevez un nouvel objet qui est logiquement « le même » (par exemple depuis une API ou un formulaire). Avec l’option equal, vous évitez les notifications et les recalculs inutiles.
import { signal } from '@angular/core';
interface Filters {
query: string;
page: number;
}
// Par défaut : égalité via Object.is (référence).
// Avec `equal`, vous décidez quand deux valeurs sont « les mêmes » → pas de notifications inutiles.
const filters = signal<Filters>(
{ query: '', page: 1 },
{ equal: (a, b) => a.query === b.query && a.page === b.page },
);
filters.set({ query: '', page: 1 }); // nouvel objet, mais « égal » → PERSONNE n’est notifié
// Aussi pour computed :
const ids = computed(() => users().map((u) => u.id), {
equal: (a, b) => a.length === b.length && a.every((id, i) => id === b[i]),
});{ query: 'angular', page: 1 }signal(filters)1chargements de la liste
- chargement n° 1 · page 1
signal(filters, { equal })1chargements de la liste
- chargement n° 1 · page 1
untracked() : lire sans dépendance
Dans un computed ou un effect, chaque signal lu devient une dépendance. Quand vous voulez seulement la valeur actuelle, sans relancer l’exécution quand elle change, utilisez untracked().
import { effect, signal, untracked } from '@angular/core';
const user = signal('Ana');
const theme = signal('dark');
effect(() => {
// Dépendance : user — l’effect se relance quand user change
const name = user();
// Lecture SANS dépendance : changer le thème ne relance PAS l’effect
const currentTheme = untracked(theme);
console.log(`[analytics] ${name} a ouvert la page (thème : ${currentTheme})`);
});
// untracked accepte aussi une fonction — utile quand vous appelez du code qui lit des signals en interne
effect(() => {
const id = selectedId();
untracked(() => this.logger.log(`Sélectionné : ${id}`)); // le logger peut lire n’importe quel signal
});const t = theme();exécutions : 1- Ana a ouvert la page (thème : dark)
const t = untracked(theme);exécutions : 1- Ana a ouvert la page (thème : dark)
Signals dans les services : écriture privée, lecture publique
Le pattern de base pour tout état partagé : le signal modifiable est private, et vous exposez à l’extérieur asReadonly() et des computed(), tandis que les changements passent par des méthodes aux noms explicites. Tous les détails sur la page State Management.
import { Service, computed, signal } from '@angular/core';
@Service()
export class CounterStore {
// L’écriture est privée : seul le store modifie son état…
private readonly _count = signal(0);
// …et à l’extérieur vous n’exposez que la lecture.
readonly count = this._count.asReadonly();
readonly isZero = computed(() => this._count() === 0);
increment(): void {
this._count.update((c) => c + 1);
}
}Quand OUI, quand NON
- L’état d’un composant (filtres, sélection, bascules, page courante).
- L’état partagé dans un service / store.
- Toute valeur affichée dans le template.
- Les valeurs dérivées : totaux, listes filtrées, indicateurs (
computed). - Les inputs de composant (
input()est aussi un signal).
- Les flux d’événements dans le temps (websocket, glisser-déposer, saisie avec opérateurs complexes) — RxJS est plus expressif dans ce cas. Voir Signals ↔ RxJS.
- Les données asynchrones du serveur — utilisez resource / httpResource, pas un signal alimenté à la main depuis un
subscribe. - Les constantes qui ne changent jamais — un simple
readonlysuffit. - L’état dérivé « modifiable » (une sélection qui se réinitialise) — utilisez linkedSignal.
Pièges fréquents
- Vous oubliez les parenthèses :
{{ count }}affiche la fonction, pas la valeur. Correct :{{ count() }}. - Vous mutez au lieu de remplacer :
push,splice,obj.x = 1ne notifient pas. - Des effets de bord dans computed : un
computedqui fait un appel HTTP ou modifie un autre signal est un bug en puissance. - De l’async dans computed :
computed(async () => ...)renvoie une Promise, pas la valeur. Utilisezresource(). - Vous ne lisez qu’une fois :
const x = this.count();dans le constructeur est un instantané, pas un lien réactif. - Synchroniser avec effect :
effect(() => b.set(a() * 2))→ utilisezcomputed(oulinkedSignalsi b doit aussi être modifiable).
Aide-mémoire
// CRÉATION
const a = signal(0); // WritableSignal<number>
const b = signal<User | null>(null); // type explicite quand on démarre avec null
const c = computed(() => a() * 2); // Signal<number>, lecture seule, lazy, mémoïsé
// LECTURE
a(); // en TS et dans le template : {{ a() }}
// ÉCRITURE
a.set(5);
a.update((v) => v + 1);
// EXPOSITION SÛRE
readonly value = this._value.asReadonly();
// OPTIONS
signal(x, { equal: (p, n) => p.id === n.id, debugName: 'selected' });
// LECTURE SANS DÉPENDANCE
untracked(a); untracked(() => a() + b());
// VÉRIFICATIONS
isSignal(x); isWritableSignal(x);