Sur la page signal() et computed(), vous avez vu ce que font les signals. Cette page montre comment ils fonctionnent de l’intérieur : un graphe réactif construit automatiquement à partir des lectures, dans lequel les écritures ne font que pousser une notification, et le recalcul n’a lieu que lorsque quelqu’un lit. Toutes les propriétés utiles en découlent : computed est lazy et mémoïsé, vous ne voyez jamais d’états intermédiaires incohérents, et Angular peut re-rendre uniquement les composants concernés, sans Zone.js.
computed coûteux ne coûte rien si personne ne le lit, pourquoi items().push() ne met pas à jour l’UI, pourquoi un effect qui modifie un autre signal est une mauvaise idée, ou pourquoi un signal lu dans une branche if « ne réagit » parfois pas.Producers, consumers et le graphe
Chaque signal est un nœud du graphe. signal() est un producer. computed() est à la fois un consumer (il lit d’autres signals) et un producer (d’autres le lisent). effect() et les templates de composants sont des consumers. Pendant l’exécution d’une fonction réactive, il existe un consumer actif, et chaque signal lu à ce moment devient sa dépendance. Le graphe n’est pas déclaré : il est construit à partir des lectures.
Chaque nœud a une version qui n’augmente que lorsqu’il produit une valeur différente de la précédente. Certains consumers sont « live » (effects et templates) ; pour eux, le graphe conserve aussi des arêtes inverses, du producer vers le consumer, afin de pouvoir leur envoyer des notifications. Les types primitifs de @angular/core/primitives/signals montrent exactement ces champs : version, dirty, producers, consumers.
const a = signal(1); // producer
const b = signal(2); // producer
const sum = computed(() => a() + b()); // consumer ET producer
const product = computed(() => a() * b());
const result = computed(() => sum() + product()); // losange : a et b arrivent ici par deux chemins
// Pendant l’exécution d’une fonction réactive, Angular garde un « consumer actif ».
// Chaque signal lu à ce moment devient sa dépendance. Vous ne déclarez rien : le graphe est construit à partir des lectures.Push, puis pull
Une écriture (set/update) ne recalcule rien. Elle augmente seulement la version du signal et prévient les consumers live qu’ils pourraient être dirty : c’est la phase push. Quand le consumer s’exécute à nouveau (le template au prochain rendu, l’effect à son tour), il lit ce dont il a besoin, et chaque computed vérifie ses producers : une version a-t-elle changé ? Alors seulement il est recalculé. C’est la phase pull.
a.set(5)
│ PUSH : a incrémente sa `version` et prévient les consumers « live » (template, effect) qu’ils PEUVENT être dirty.
│ Rien n’est encore recalculé.
▼
template / effect marqué dirty → la Change Detection planifie un rendu
│
▼
le template lit result()
│ PULL : result vérifie ses producers (sum, product) — ont-ils une version différente ?
│ sum vérifie ses producers (a, b) — a a une version différente → sum est recalculé.
│ Si la nouvelle valeur est ÉGALE à l’ancienne, la version de sum n’augmente pas → la propagation s’arrête.
▼
result est recalculé une fois, avec des valeurs déjà cohérentes (glitch-free)La conséquence importante : un computed n’est recalculé qu’après la fin de toutes les écritures, avec des valeurs déjà cohérentes. Dans le losange de la démo, result ne voit jamais un nouveau sum avec un ancien product. C’est ce qu’on appelle glitch-free. Et si vous modifiez a et b dans le même clic, result n’est recalculé qu’une seule fois.
Dans le template : a = 1, b = 2 · result = 5
Lazy et mémoïsation
La documentation d’Angular le dit directement : un computed est évalué paresseusement (lazy) et mémoïsé. La fonction ne s’exécute pas avant la première lecture. Ensuite, la valeur est conservée et réutilisée jusqu’à ce qu’une dépendance change. En pratique, vous pouvez placer des dérivations coûteuses (filtrage, tri) dans un computed : elles ne coûtent quelque chose que lorsque le résultat est réellement nécessaire.
const expensive = computed(() => {
console.log('calcul…');
return items().filter(matches).sort(byPrice);
});
items.set(newItems); // rien ne s’affiche : personne n’a encore lu
expensive(); // « calcul… » — première lecture
expensive(); // rien : valeur mémoïsée
items.set(other); // toujours rien — marqué comme périmé, non recalculé
expensive(); // « calcul… »Égalité : là où la propagation s’arrête
La version d’un nœud n’augmente que si la nouvelle valeur n’est pas « égale » à l’ancienne. Par défaut, l’égalité est Object.is : pour les objets et les tableaux, c’est la référence qui compte, pas le contenu. Le même mécanisme s’applique à un computed. Si son résultat ne change pas, ses consumers ne sont pas recalculés, même si ses dépendances ont changé. Avec { equal }, vous définissez ce que « identique » veut dire.
// Par défaut : Object.is. Pour les objets/tableaux, c’est la RÉFÉRENCE qui compte.
const tags = signal(['a', 'b']);
tags.set(['a', 'b']); // autre référence → « nouvelle » valeur → les consumers se recalculent
// Égalité personnalisée : la valeur ne change pas si elle est « identique » selon votre règle
const sameItems = (x: string[], y: string[]) => x.length === y.length && x.every((v, i) => v === y[i]);
const stableTags = signal(['a', 'b'], { equal: sameItems });
stableTags.set(['a', 'b']); // égal → la version n’augmente pas → rien en aval
// Un computed a aussi une égalité : si son résultat est égal, la propagation s’arrête là
const parity = computed(() => (n() % 2 ? 'impair' : 'pair')); // n: 0 → 2 → 4 … parity reste 'pair'| Nœud | Valeur | Recalculs |
|---|---|---|
parity = computed(() => n() % 2 ? 'impair' : 'pair') | ||
label = computed(() => 'Le nombre est ' + parity()) |
| Signal | count = computed(tags().length) |
|---|---|
signal(...) — égalité par défaut (Object.is) | 2 · recalculé × |
signal(..., { equal: sameItems }) | 2 · recalculé × |
Dépendances dynamiques
À chaque exécution, la liste des producers d’un consumer est reconstruite à partir de ce qu’il a réellement lu. Un signal lu uniquement dans une branche if n’est une dépendance que tant que cette branche s’exécute. C’est un gain de performance, mais aussi une source de confusion : « je modifie details et rien ne se passe » est généralement normal, car la branche courante ne le lit pas.
const view = computed(() => {
if (showDetails()) {
return details(); // dépendances : showDetails + details
}
return summary(); // dépendances : showDetails + summary
});
// À chaque exécution, la liste des producers est reconstruite à partir de ce qui a été EFFECTIVEMENT lu.
// Tant que showDetails() est false, modifier details ne recalcule pas view.view() = summary v0
Dépendances enregistrées lors de la dernière exécution : · view recalculé ×
Effects et templates : les consumers live
Les effects s’exécutent de façon asynchrone, pendant le processus de Change Detection, et non au moment du set(). La documentation v22 distingue deux types. Un view effect est créé dans le contexte d’un composant : il s’exécute avant la vérification de ce composant et est détruit avec lui. Un root effect est créé dans un service fourni dans root : il s’exécute avant la vérification de tous les composants et vit aussi longtemps que l’application. Si une dépendance change pendant l’exécution de l’effect, celui-ci s’exécute une fois de plus avant que la Change Detection ne continue.
// View effect : créé dans le contexte d’un composant → s’exécute AVANT que ce composant
// soit vérifié par la Change Detection ; il est détruit avec le composant.
@Component({ /* ... */ })
export class Profile {
constructor() {
effect(() => console.log('profil', this.user()));
}
}
// Root effect : créé dans un service fourni dans root → s’exécute avant la vérification de tous les composants ;
// il vit aussi longtemps que l’application.
@Injectable({ providedIn: 'root' })
export class Analytics {
constructor() {
effect(() => track(this.route()));
}
}
// Les deux sont asynchrones (ils ne s’exécutent pas au moment du set()) et liés au processus de Change Detection.Le template d’un composant est aussi un consumer réactif. Quand un signal lu dans le template change, Angular marque le composant pour vérification (comme markForCheck), et la Change Detection ne re-rend que lui et ses ancêtres. C’est sur cela que reposent les applications zoneless et l’OnPush par défaut de la v22 (voir Change detection).
@Component({
template: `<p>{{ fullName() }}</p>`, // le template est un consumer réactif
})
export class Card {
readonly first = signal('Ana');
readonly fullName = computed(() => `${this.first()} Pop`);
}
// first.set('Ioana') → le nœud réactif du template est marqué dirty → le composant est marqué pour
// vérification (comme markForCheck) → la Change Detection le re-rend. Pas de Zone.js, pas de vérification
// de tout l’arbre : c’est ainsi que fonctionnent les applications zoneless.Quand OUI, quand NON
- Des dérivations coûteuses dans un
computed: elles ne sont calculées qu’en cas de besoin et une seule fois par changement. - Des chaînes de computed plutôt qu’un seul computed géant. Avec la coupure par égalité, les petits changements s’arrêtent tôt.
{ equal }pour les valeurs souvent recréées mais « identiques » (filtres, listes d’identifiants venant du serveur).untrackedpour les lectures qui ne doivent rien déclencher (logging, analytics).
- Il ne détecte pas les mutations : un
pushsur un tableau dans un signal ne notifie personne. - Il n’ordonne pas vos effets de bord : si vous avez besoin d’un ordre strict dans le temps, utilisez RxJS (voir RxJS avancé).
- Ce n’est pas un mécanisme d’événements : « un clic a eu lieu » n’est pas un état. Utilisez
output()ou un Subject. - Il n’exécute pas les effects de façon synchrone : si vous avez besoin du résultat immédiatement, dérivez-le avec
computed.
Pièges fréquents
// ✗ Écriture dans un computed — erreur : "Writing to signals is not allowed in a `computed`"
const total = computed(() => {
count.set(items().length); // effet de bord dans une dérivation
return items().reduce(sum, 0);
});
// ✗ Effect utilisé pour une dérivation (un rendu de plus, risque de boucles)
effect(() => total.set(items().reduce(sum, 0)));
// ✓ La dérivation est un computed
const total2 = computed(() => items().reduce(sum, 0));
// ✗ Mutation : même référence → Object.is dit « égal » → rien ne se met à jour
items().push(newItem);
// ✓
items.update((list) => [...list, newItem]);- « Ça ne réagit pas » : vérifiez si le signal est lu dans la branche qui s’exécute, et si vous le lisez bien avec
(). Une référence à un signal sans l’appeler ne crée pas de dépendance. - De gros objets dans un seul signal : tout champ modifié recalcule tout ce qui dépend de l’objet. Découpez l’état, ou dérivez les champs avec
computed. - Un effect qui écrit dans d’autres signals : rendus supplémentaires et risque de boucles. Presque toujours, la réponse est un
computedou un linkedSignal. - Lire dans le constructeur « pour que ce soit prêt » : un computed lu trop tôt est calculé avec des données incomplètes. Laissez-le lazy.
Aide-mémoire
NŒUD RÔLE QUAND IL S’EXÉCUTE
signal producer — (il ne change que sa valeur et sa version lors d’un set/update)
computed consumer + producer lazy : à la lecture, seulement si une dépendance a changé
effect consumer « live » de façon asynchrone, pendant la Change Detection (view : avant le composant ; root : avant tous)
template consumer « live » quand le composant est re-rendu après avoir été marqué dirty
RÈGLES
dépendances = ce que vous avez EFFECTIVEMENT lu lors de la dernière exécution (dynamiques, par branche)
égalité par défaut Object.is ; { equal } pour les valeurs « identiques » → stoppe la propagation
un computed sans lecteurs n’est pas calculé du tout
n’écrivez pas de signals dans un computed ; ne dérivez pas d’état avec un effect
untracked(() => …) lit sans créer de dépendance