📧 Reste informé(e) !

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

S'inscrire gratuitement

~9 min de lecture

Signal equal : pourquoi ton set() avec un objet ne re-render rien

Tu appelles user.set({ ...current, name: 'Alice' }). Ton composant ne bouge pas. Tu ajoutes un console.log dans un effect() juste pour voir. Rien. Tu changes pour computed(). Toujours rien. Tu commences à douter d'Angular, puis de toi, puis de l'univers.

Puis tu regardes ton code. Et là, tu vois : ton parent a fait mutate(user, u => u.name = 'Alice') puis passé le même signal, et ton computed() a décidé — à raison — que rien n'avait changé. Ou l'inverse : tu fais set(newArray) en boucle avec des tableaux identiques, et Angular repart en re-render à chaque tick.

La fonction equal des signals est ce qui décide, en coulisses, si un set() propage ou pas. Par défaut elle utilise Object.is, ce qui marche parfaitement pour les primitives et te trompe systématiquement dès que tu quittes ce terrain. Ce guide couvre les 5 pièges qui reviennent sans arrêt, avec à chaque fois le before/after et la règle pour ne plus les commettre.

Valide Angular 17+ (introduction des Signals stable). Le paramètre equal existe sur signal(), computed() et linkedSignal().


TL;DR

Piège Symptôme Correctif
Muter l'objet puis set(sameRef) Rien ne bouge dans le template Toujours produire une nouvelle référence
Deep-equal naïf en equal Perf qui s'écroule sur les gros objets Egaliteur ciblé sur le champ qui compte
equal custom sur un signal de collection ordonnée UI reste sur l'ordre précédent, tri ignoré Prendre en compte l'ordre dans l'égaliteur
Oublier equal sur un computed() qui expose un objet dérivé Cascade de re-renders identiques Fournir un equal structurel sur le computed
Signal input reçoit un nouvel objet identique Enfant re-render alors que rien n'a changé computed({ equal }) en aval, ou stabiliser côté parent

Règle : equal est une gate d'invalidation. Si tu changes son comportement, tu changes la propagation. Vérifie qu'il matche exactement la sémantique que ton signal expose.


Comment un signal décide qu'il a changé (le rappel de 30 secondes)

Chaque signal Angular garde en mémoire sa valeur précédente. À chaque set() ou update(), il appelle sa fonction equal(prev, next). Si elle renvoie true, Angular considère qu'il n'y a rien à propager : les computed() en aval ne se recalculent pas, les effect() ne se rejouent pas, le template ne se re-render pas.

Par défaut, equal est Object.is. Ce qui donne le comportement suivant :

import { signal } from '@angular/core';

const count = signal(0);
count.set(0); // Object.is(0, 0) = true → aucune propagation ✅
count.set(1); // Object.is(0, 1) = false → propagation ✅

const user = signal({ name: 'Alice' });
user.set({ name: 'Alice' }); // Object.is({...}, {...}) = false → propagation ⚠️

Sur les primitives, Object.is fait exactement ce que tu veux. Sur les objets et les arrays, Object.is compare par référence : deux objets créés avec le même contenu sont considérés différents, et deux mutations sur la même référence sont considérées identiques. C'est là que tout dérape.


Piège 1 — Muter un objet puis set() la même référence

C'est le grand classique des devs qui viennent de Vue ou d'Angular pre-Signals. Tu es habitué à ce que la réactivité "voie" les changements de propriétés. En Signals, ce n'est pas le cas.

Ce qui pique

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

type User = { name: string; age: number };

@Component({
  selector: 'app-profile',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>{{ user().name }} — {{ user().age }} ans</p>
    <button (click)="birthday()">Anniversaire</button>
  `,
})
export class Profile {
  protected readonly user = signal<User>({ name: 'Alice', age: 30 });

  protected birthday(): void {
    const current = this.user();
    current.age += 1;      // mutation en place
    this.user.set(current); // même référence
  }
}

Object.is(current, current) renvoie true. Angular en déduit que la valeur n'a pas changé, ne propage rien, et le template reste sur "30 ans" pour toujours. Le bug est d'autant plus vicieux que tu peux le voir se corriger dès qu'un autre signal du composant change et déclenche une re-lecture.

Le correctif

protected birthday(): void {
  this.user.update((u) => ({ ...u, age: u.age + 1 }));
}

update() avec un spread produit une nouvelle référence. Object.is(prev, next) renvoie false, la propagation part.

Règle d'or : un signal d'objet doit toujours être mis à jour via une nouvelle référence. Si tu es tenté de muter puis set(), c'est que tu forces un modèle mental qui ne colle pas à l'API.


Piège 2 — equal deep-equal naïf sur un gros graphe

L'inverse du piège 1 : tu as un signal qui reçoit du backend un JSON de 500 lignes toutes les 10 secondes. Chaque payload arrive avec une nouvelle référence, Object.is dit "différent", tout se recalcule — même quand la réponse est identique au caractère près. Ton effect() fait un fetch en cascade, tes computed() recompilent leurs listes, et le CPU s'envole.

L'instinct est de coller un equal: deepEqual importé de lodash sur le signal et de laisser filer.

Ce qui pique

import { signal } from '@angular/core';
import { isEqual } from 'lodash-es';

type Report = { rows: Array<{ id: string; values: number[] }> };

// Chaque set() force une comparaison profonde du graphe complet
const report = signal<Report>({ rows: [] }, { equal: isEqual });

Sur un rapport de 500 rows avec des tableaux imbriqués, isEqual visite l'intégralité du graphe à chaque set(). En pratique tu remplaces un rebuild coûteux par une comparaison presque aussi coûteuse — et tu la paies à chaque update, y compris quand tu vas propager ensuite.

Le correctif

Egalise sur le champ qui a une identité stable côté backend, pas sur le graphe entier :

import { signal } from '@angular/core';

type Report = { version: string; rows: Array<{ id: string; values: number[] }> };

const report = signal<Report>(
  { version: '', rows: [] },
  { equal: (a, b) => a.version === b.version },
);

Si ton backend expose un ETag, un updatedAt ou un version, c'est la bonne clé de comparaison : O(1) au lieu de O(n). Si aucun champ ne joue ce rôle, discute avec l'équipe API avant de te lancer dans du deep-equal.

Règle d'or : un equal custom doit être moins coûteux que le travail qu'il permet d'éviter. Sinon, autant garder le défaut.


Piège 3 — equal custom qui ignore l'ordre d'une collection

Tu as un signal qui expose une liste triée. Tu ajoutes un equal custom parce que ton backend te renvoie parfois deux références différentes avec les mêmes items — tu veux éviter les re-renders inutiles.

Ce qui pique

import { signal } from '@angular/core';

type Task = { id: string; label: string };

// Vérifie que les mêmes IDs sont présents des deux côtés, sans regarder l'ordre
const sameTaskIds = (a: Task[], b: Task[]) =>
  a.length === b.length && a.every((t) => b.some((x) => x.id === t.id));

const tasks = signal<Task[]>([], { equal: sameTaskIds });

// Au premier chargement
tasks.set([{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }]);

// Après un tri côté client
tasks.set([{ id: 'b', label: 'B' }, { id: 'a', label: 'A' }]);

À la deuxième set(), ton comparateur trouve les mêmes IDs des deux côtés — peu importe leur position — et renvoie true. Ton signal considère les deux tableaux identiques, la propagation ne part pas, le tri reste invisible dans le template.

Le correctif

Décide explicitement de la sémantique que tu exposes :

import { signal } from '@angular/core';

type Task = { id: string; label: string };

// L'ordre compte : on compare position par position
const equalOrderedIds = (a: Task[], b: Task[]) =>
  a.length === b.length && a.every((t, i) => t.id === b[i].id);

const tasks = signal<Task[]>([], { equal: equalOrderedIds });

Si l'ordre n'a pas de sens fonctionnel pour ton signal, ne fais pas de tri client dessus — ou expose deux signals : un pour la source, un computed() pour la version triée.

Règle d'or : ton equal est un contrat sur ce qui compte pour ce signal. Écris-le comme une phrase : "deux listes de tâches sont identiques si elles ont les mêmes IDs dans le même ordre".


Piège 4 — Oublier equal sur un computed() qui expose un objet dérivé

computed() a lui aussi une option equal, avec le même défaut Object.is. Sauf que le job d'un computed() c'est justement de recréer une valeur dérivée à chaque changement de ses dépendances — souvent un objet.

Résultat : deux dépendances qui bougent alors que le résultat dérivé est identique déclenchent quand même une propagation en cascade vers les computed() d'en dessous, et vers les templates qui les consomment.

Ce qui pique

import { computed, signal } from '@angular/core';

type Range = { min: number; max: number };

const items = signal([1, 2, 3, 4, 5]);
const filterFn = signal<(n: number) => boolean>((n) => n > 0);

// Dérivation : le min et le max des items qui passent le filtre
const range = computed<Range>(() => {
  const filtered = items().filter(filterFn());
  return { min: Math.min(...filtered), max: Math.max(...filtered) };
});

Le jour où tu changes filterFn pour un filtre qui garde les mêmes items, range recalcule et produit un { min, max } avec les mêmes chiffres — mais une nouvelle référence. Tous les computed() en aval qui dépendent de range se réveillent pour rien. Sur une hiérarchie de dérivés, le coût s'empile vite.

Le correctif

import { computed, signal } from '@angular/core';

type Range = { min: number; max: number };

const items = signal([1, 2, 3, 4, 5]);
const filterFn = signal<(n: number) => boolean>((n) => n > 0);

const range = computed<Range>(
  () => {
    const filtered = items().filter(filterFn());
    return { min: Math.min(...filtered), max: Math.max(...filtered) };
  },
  { equal: (a, b) => a.min === b.min && a.max === b.max },
);

computed() mémoïse toujours son résultat, mais l'equal custom lui permet de détecter que le résultat est structurellement identique et de couper la propagation en aval. Les Object.is en cascade s'arrêtent au premier equal qui dit "rien à faire".

Règle d'or : dès qu'un computed() renvoie un objet ou un array, demande-toi si deux exécutions successives peuvent produire une valeur structurellement égale. Si oui, colle un equal structurel adapté à la forme retournée.


Piège 5 — Signal input qui reçoit toujours un nouvel objet

input() est un signal en lecture seule. Il tombe dans le même piège que signal() : chaque nouvelle référence passée par le parent invalide toutes les dérivations enfant, même quand le contenu est identique.

Le cas typique : un composant enfant qui reçoit une config, et un parent qui recompose cette config à chaque tick.

Ce qui pique

import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';

type Config = { theme: 'light' | 'dark'; density: number };

@Component({
  selector: 'app-widget',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<pre>{{ label() }}</pre>`,
})
export class Widget {
  readonly config = input.required<Config>();

  protected readonly label = computed(
    () => `${this.config().theme} @ ${this.config().density}`,
  );
}
// Le parent
@Component({
  template: `<app-widget [config]="{ theme: theme(), density: 1 }" />`,
})
export class Host {
  protected readonly theme = signal<'light' | 'dark'>('light');
}

Chaque change detection réévalue l'expression du template et produit un nouvel objet { theme, density }. Le signal config passe Object.is à false, label recalcule pour rien.

Le correctif

Contrairement à signal(), computed() et linkedSignal(), input() n'expose pas d'option equal : impossible de court-circuiter la comparaison à la source. Deux leviers restent ouverts.

1. Couper la propagation juste après l'input, avec un computed() intermédiaire qui porte, lui, un equal structurel :

export class Widget {
  readonly config = input.required<Config>();

  private readonly stableConfig = computed(
    () => this.config(),
    { equal: (a, b) => a.theme === b.theme && a.density === b.density },
  );

  protected readonly label = computed(
    () => `${this.stableConfig().theme} @ ${this.stableConfig().density}`,
  );
}

label ne dépend plus directement de config mais de stableConfig, qui absorbe les références jetables et ne propage que quand le contenu change vraiment.

2. Stabiliser la référence côté parent, en amont du problème : promeut la config en signal() ou computed({ equal }) plutôt que de la reconstruire à chaque évaluation de template. Le composant enfant ne reçoit plus des références jetables mais une source stable, et n'a même pas besoin du computed intermédiaire.

Règle d'or : un signal input qui expose un objet mérite le même soin qu'un computed() — pense à qui tu es en tant que source pour l'enfant, pas seulement à qui tu es en tant que consommateur pour le parent.


Récap actionnable

Situation Ce qu'il faut faire
Signal de primitive (number, string, boolean) Défaut Object.is — ne touche à rien
Signal d'objet mis à jour immutablement Défaut Object.is — spread et laisse Angular propager
Signal d'objet reçu du backend avec une version stable equal: (a, b) => a.version === b.version
computed() qui expose un objet dérivé re-créé à chaque appel equal structurel sur les champs qui comptent
Signal input avec un parent qui recrée l'objet à chaque tick computed({ equal }) en aval de l'input, ou stabiliser la référence côté parent
Collection dont l'ordre a un sens fonctionnel Comparateur position par position, jamais some()/includes()
Deep-equal générique tentant Seulement si le graphe est petit et que la propagation évitée est massive

Le fond du sujet : equal n'est pas un détail de perf. C'est le contrat sémantique que ton signal expose au reste du système. Le défaut Object.is marche pour les primitives et pour les objets utilisés immutablement — deux mondes où "identique" veut dire "même référence". Tout le reste demande une décision explicite.

Si tu écris ton equal, écris-le d'abord en français comme une phrase, et vérifie qu'elle est vraie : "deux users sont identiques si leur id est identique". Puis code la phrase. Le jour où quelqu'un change ce contrat (l'id devient nullable, l'ordre commence à compter, un nouveau champ change la sémantique), tu n'auras qu'un seul endroit à toucher — et tu comprendras pourquoi ton composant re-render, ou pourquoi il ne le fait pas.

📧 Reste informé(e) !

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

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.