Avez-vous une question? (216) 97 656 803 hajjriadh@gmail.com
Angular

Les signals dans Angular

Un signal est une valeur qui prévient automatiquement ceux qui la lisent quand elle change. C’est grâce à eux qu’Angular 22, sans zone.js et avec OnPush par défaut, met à jour exactement ce qui doit l...

0 chapitres 0 QCM Accès gratuit
Dans cet article

À propos de ce cours

Un signal est une valeur qui prévient automatiquement ceux qui la lisent quand elle change. C’est grâce à eux qu’Angular 22, sans zone.js et avec OnPush par défaut, met à jour exactement ce qui doit l’être. Ce chapitre présente tous les outils : signal(), set(), update(), asReadonly() et l’option equal, avec la règle d’or des objets et des tableaux : toujours créer une nouvelle valeur ; computed() : des valeurs dérivées mémorisées, paresseuses et en lecture seule ; effect() : les actions vers le monde extérieur (stockage, titre, minuteur), avec onCleanup et untracked, et les cas où il ne faut pas l’utiliser ; linkedSignal() : une valeur dérivée que l’utilisateur peut modifier ; resource() : des données asynchrones exposées en signals (value, status, isLoading, error, reload) ; toSignal() et toObservable() : le pont avec RxJS ; les pièges les plus courants et le choix du bon outil.
  1. Objectifs

    • Comprendre ce qu'est un signal et pourquoi Angular s'appuie sur eux pour mettre l'affichage à jour
    • Créer, lire et modifier un signal avec signal(), set() et update(), sans modifier les objets sur place
    • Calculer des valeurs dérivées avec computed(), et des valeurs dérivées modifiables avec linkedSignal()
    • Déclencher des actions avec effect(), et savoir quand ne pas l'utiliser
    • Charger des données asynchrones avec resource() et suivre leur état
    • Faire le lien avec RxJS grâce à toSignal() et toObservable()
  1. Présentation

    • Un signal est une boîte qui contient une valeur et qui prévient automatiquement tous ceux qui l'ont lue lorsque cette valeur change. Le template, un computed ou un effect qui lisent un signal en deviennent les consommateurs.
    • C'est sur ce mécanisme que repose l'Angular moderne : depuis Angular 21, les nouveaux projets n'utilisent plus zone.js, et Angular 22 applique OnPush par défaut. Angular ne met donc à jour un composant que lorsqu'un signal qu'il affiche a changé (voir « Le data binding dans Angular »).
    • Vous utilisez déjà des signals depuis le début de ce cours : input(), model(), viewChild() et les Signal Forms en créent tous. Ce chapitre présente l'ensemble des outils et la manière de bien les combiner.

Un signal prévient le template, les computed et les effets qui l'ont lu

Les fonctions des signals d'Angular

  • Pour suivre ce chapitre, continuez dans le projet premiers-pas-angular et créez un dossier src/app/signals/. Tous les exemples ont été testés avec Angular 22.
  1. Créer, lire et modifier un signal

    • signal(valeurInitiale) crée un signal modifiable (type WritableSignal<T>). On le lit en l'appelant: compteur(). On le modifie avec set(nouvelleValeur), ou avec update(ancienne => nouvelle) quand la nouvelle valeur dépend de l'ancienne.
    • Un signal ne prévient ses consommateurs que s'il reçoit une valeur différente. Par défaut, il compare avec Object.is; l'option equal permet de définir sa propre règle.
import { Component, Signal, WritableSignal, signal } from '@angular/core';

interface Point {
  x: number;
  y: number;
}

@Component({
  selector: 'app-bases',
  template: `
    <p>Compteur : {{ compteur() }}</p>
    <button (click)="compteur.set(0)">Remettre à zéro</button>
    <button (click)="compteur.update((n) => n + 1)">+1</button>

    <p>Inscrits : {{ inscrits().join(', ') }} ({{ inscrits().length }})</p>
    <button (click)="inscrire('Sami')">Inscrire Sami</button>

    <p>Position : {{ position().x }}, {{ position().y }} – changements : {{ changements }}</p>
    <button (click)="deplacer(0, 0)">Même position</button>
    <button (click)="deplacer(10, 5)">Déplacer</button>
  `,
})
export class Bases {
  // Un signal modifiable : sa valeur se lit en l'appelant, se modifie avec set() ou update()
  protected readonly compteur: WritableSignal<number> = signal(0);
  protected readonly inscrits = signal<string[]>(['Amira']);

  // equal : fonction qui décide si la nouvelle valeur est « la même » que l'ancienne
  protected readonly position = signal<Point>({ x: 0, y: 0 }, { equal: (a, b) => a.x === b.x && a.y === b.y });
  protected changements = 0;

  // Version en lecture seule, à exposer aux autres classes
  readonly compteurLecture: Signal<number> = this.compteur.asReadonly();

  protected inscrire(nom: string): void {
    this.inscrits.update((liste) => [...liste, nom]); // nouveau tableau
  }

  protected deplacer(x: number, y: number): void {
    const avant = this.position();
    this.position.set({ x, y });
    if (this.position() !== avant) this.changements++;
  }
}
Compteur : 0
Inscrits : Amira (1)
Position : 0, 0 – changements : 0
(après +1, +1, Inscrire Sami, Même position, Déplacer, Déplacer)
Compteur : 2
Inscrits : Amira, Sami (2)
Position : 10, 5 – changements : 1
  • inscrits.update((liste) => [...liste, nom]) crée un nouveau tableau. C'est indispensable : un signal ne voit pas qu'on modifie l'intérieur d'un tableau ou d'un objet (voir « Les pièges »).
  • Grâce à equal, « Même position » et le second « Déplacer » sont ignorés : la position n'a pas vraiment changé, le signal garde son ancien objet et ne prévient personne. Seul le premier déplacement compte.
  • asReadonly() renvoie une vue en lecture seule (type Signal<T>) : on l'expose aux autres classes pour qu'elles puissent lire sans modifier. Un service garde ainsi le contrôle de ses données.
  • Attention : sans parenthèses, on manipule le signal lui-même, pas sa valeur. this.compteur + 1 est refusé : TS2365: Operator '+' cannot be applied to types 'WritableSignal<number>' and 'number'.
  1. computed(): les valeurs dérivées

    • computed(() => …) crée un signal en lecture seule dont la valeur est calculée à partir d'autres signals. Angular retient automatiquement les signals lus pendant le calcul : ce sont ses dépendances.
    • Un computed est mémorisé (le résultat est réutilisé tant que les dépendances ne changent pas) et paresseux (il n'est calculé que lorsque quelqu'un le lit).
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-calculs',
  imports: [FormsModule],
  template: `
    <label>Prix <input type="number" [(ngModel)]="prix" /></label>
    <label>Quantité <input type="number" [(ngModel)]="quantite" /></label>
    <label>Code promo <input [(ngModel)]="code" /></label>
    <p>Sous-total : {{ sousTotal() }} DT – à payer : {{ aPayer() }} DT – (sous-total relu : {{ sousTotal() }} DT)</p>
    <p>{{ message() }}</p>
  `,
})
export class Calculs {
  protected readonly prix = signal(120);
  protected readonly quantite = signal(2);
  protected readonly code = signal('');

  protected readonly sousTotal = computed(() => {
    console.log('calcul du sous-total');
    return this.prix() * this.quantite();
  });

  // Dépendances dynamiques : sousTotal() n'est lu que si le code est valide
  protected readonly aPayer = computed(() => {
    console.log('calcul du montant à payer');
    return this.code() === 'ANGULAR22' ? this.sousTotal() * 0.8 : this.prix() * this.quantite();
  });

  protected readonly message = computed(() =>
    this.code() === 'ANGULAR22' ? 'Code ANGULAR22 appliqué : −20 %' : 'Aucune réduction',
  );
}
Sous-total : 240 DT – à payer : 240 DT – (sous-total relu : 240 DT)
(quantité → 3)        console : calcul du sous-total
                                calcul du montant à payer
Sous-total : 360 DT – à payer : 360 DT – (sous-total relu : 360 DT)
(code → ANGULAR22)    console : calcul du montant à payer
Sous-total : 360 DT – à payer : 288 DT – Code ANGULAR22 appliqué : −20 %

computed mémorisé, paresseux et à dépendances dynamiques

  • Le sous-total est affiché deux fois, mais la console n'affiche qu'un « calcul du sous-total » par changement : la seconde lecture réutilise le résultat mémorisé.
  • Quand seul le code promo change, le sous-total n'est pas recalculé : ses dépendances (prix, quantite) n'ont pas bougé.
  • Les dépendances sont dynamiques: tant que le code est invalide, aPayer ne lit pas sousTotal() et n'en dépend donc pas.
  • Règle : un computed doit être une fonction pure: il calcule, il ne modifie rien. Écrire dans un signal depuis un computed provoque l'erreur NG0600: Writing to signals is not allowed in a `computed`., et il n'a pas de méthode set() (TS2339: Property 'set' does not exist on type 'Signal<number>'.).
  1. effect(): réagir par une action

    • effect(() => …) exécute une fonction une première fois, puis à chaque changement des signals qu'elle a lus. Il sert à synchroniser les signals avec le monde extérieur : stockage local, titre de la page, journal, minuteur, bibliothèque externe.
    • Un effet se crée dans un contexte d'injection: dans le constructeur ou l'initialisation d'une propriété. Il est arrêté automatiquement à la destruction du composant.
import { Component, effect, signal, untracked } from '@angular/core';

@Component({
  selector: 'app-effets',
  template: `
    <p>Thème : {{ theme() }} – notifications : {{ nonLues() }}</p>
    <button (click)="theme.set(theme() === 'clair' ? 'sombre' : 'clair')">Changer de thème</button>
    <button (click)="nonLues.update((n) => n + 1)">Nouveau message</button>
    <button (click)="auteur.set('Karim')">Changer d'auteur</button>
    <p>Rappel dans : {{ rappel() }} s</p>
  `,
})
export class Effets {
  protected readonly theme = signal<'clair' | 'sombre'>(this.lireTheme());
  protected readonly nonLues = signal(0);
  protected readonly auteur = signal('Amira');
  protected readonly rappel = signal(3);

  constructor() {
    // 1. Synchroniser une valeur avec le navigateur (stockage local)
    effect(() => {
      localStorage.setItem('theme', this.theme());
      console.log(`thème enregistré : ${this.theme()}`);
    });

    // 2. Mettre à jour le titre de l'onglet ; auteur() est lu sans créer de dépendance
    effect(() => {
      const n = this.nonLues();
      document.title = n ? `(${n}) Messages` : 'Messages';
      console.log(`titre : ${document.title} – dernier auteur : ${untracked(this.auteur)}`);
    });

    // 3. Un minuteur relancé à chaque changement de thème, arrêté par onCleanup
    effect((onCleanup) => {
      this.theme();
      this.rappel.set(3);
      const id = setInterval(() => this.rappel.update((s) => Math.max(0, s - 1)), 1000);
      onCleanup(() => clearInterval(id));
    });
  }

  private lireTheme(): 'clair' | 'sombre' {
    try {
      return localStorage.getItem('theme') === 'sombre' ? 'sombre' : 'clair';
    } catch {
      return 'clair';
    }
  }
}
(au chargement)          thème enregistré : clair
                         titre : Messages – dernier auteur : Amira
(2 × Nouveau message)    titre : (1) Messages – dernier auteur : Amira
                         titre : (2) Messages – dernier auteur : Amira     ← onglet « (2) Messages »
(Changer d'auteur)       (rien : auteur est lu avec untracked)
(Changer de thème)       thème enregistré : sombre     ← le rappel repart de 3 s

Le cycle d'un effet et ses bons usages

  • untracked(this.auteur) lit un signal sans en faire une dépendance : changer l'auteur ne relance pas l'effet du titre.
  • onCleanup(fn) enregistre une fonction de nettoyage, appelée avant la prochaine exécution et à la destruction : le minuteur précédent est arrêté avant d'en créer un nouveau.
  • Les effets sont planifiés: ils s'exécutent un peu après le changement, pendant la mise à jour de l'affichage, et une seule fois même si plusieurs signals ont changé entre-temps.
  • Attention : créer un effet dans une méthode provoque NG0203: effect() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`.
  • À éviter : utiliser un effet pour copier un signal dans un autre. Pour calculer, utilisez computed(); pour une valeur calculée mais modifiable, linkedSignal().
  1. linkedSignal(): dérivé, mais modifiable

    • Certaines valeurs dépendent d'une autre et doivent pouvoir être changées par l'utilisateur : la formation sélectionnée dépend de la catégorie, mais l'utilisateur peut en choisir une autre. linkedSignal est fait pour cela : il se recalcule quand sa source change, et accepte set() entre-temps.
    • Sa forme longue reçoit la valeur précédente: on peut ainsi conserver un choix de l'utilisateur lorsque c'est possible.

linkedSignal : recalculé quand la source change, modifiable entre-temps

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

interface Formation {
  nom: string;
  placesMax: number;
}

const CATALOGUE: Record<string, Formation[]> = {
  Web: [{ nom: 'Angular', placesMax: 4 }, { nom: 'React', placesMax: 3 }, { nom: 'Vue', placesMax: 2 }],
  Mobile: [{ nom: 'Flutter', placesMax: 5 }, { nom: 'Kotlin', placesMax: 2 }],
  Données: [{ nom: 'Python', placesMax: 6 }, { nom: 'SQL', placesMax: 3 }],
};

@Component({
  selector: 'app-selection',
  template: `
    @for (c of categories; track c) {
      <button [class.active]="c === categorie()" (click)="categorie.set(c)">{{ c }}</button>
    }
    <p>
      @for (f of formations(); track f.nom) {
        <label><input type="radio" name="formation" [checked]="f === choix()" (change)="choix.set(f)" /> {{ f.nom }}</label>
      }
    </p>
    <p>Choix : {{ choix().nom }} – places : {{ places() }} / {{ choix().placesMax }}</p>
    <button (click)="places.update((n) => Math.min(n + 1, choix().placesMax))">+1 place</button>
  `,
  styles: `.active { outline: 3px solid #DD0031; } label { margin-right: 12px; }`,
})
export class Selection {
  protected readonly Math = Math;
  protected readonly categories = Object.keys(CATALOGUE);
  protected readonly categorie = signal('Web');
  protected readonly formations = computed(() => CATALOGUE[this.categorie()]);

  // Forme courte : revient à la première formation à chaque changement de catégorie,
  // mais reste modifiable avec set()
  protected readonly choix = linkedSignal(() => this.formations()[0]);

  // Forme longue : garde le nombre de places choisi, plafonné par la nouvelle formation
  protected readonly places = linkedSignal<Formation, number>({
    source: this.choix,
    computation: (formation, precedent) => Math.min(precedent?.value ?? 1, formation.placesMax),
  });
}
Choix : Angular – places : 1 / 4
(3 × +1 place)          Choix : Angular – places : 4 / 4
(React)                 Choix : React – places : 3 / 3     ← plafonné
(Vue)                   Choix : Vue – places : 2 / 2
(catégorie Données)     Choix : Python – places : 2 / 6    ← nouveau choix, places conservées
(+1 place)              Choix : Python – places : 3 / 6
  • choix revient à la première formation de la catégorie dès que formations() change, mais choix.set(f) reste possible à tout moment.
  • places utilise la forme longue : computation(source, precedent) garde le nombre de places choisi (precedent.value) en le plafonnant à la capacité de la nouvelle formation.
  • Avec un simple computed, on ne pourrait pas modifier la valeur ; avec un signal mis à jour par un effect, on obtiendrait le même résultat de façon plus fragile et plus lente.
  1. resource(): des données asynchrones

    • resource(), stable depuis Angular 22, charge une donnée de façon asynchrone et l'expose sous forme de signals : value(), status(), isLoading(), error(), hasValue().
    • La fonction params lit des signals : dès que l'un d'eux change, le loader est relancé, et le chargement précédent est annulé grâce à abortSignal. reload() relance le chargement à la demande.

Les états d'une resource et ses signals

  • Placez ce fichier stagiaires.json dans le dossier public/:
[
  { "id": 1, "nom": "Amira Ben Ali", "ville": "Sousse", "moyenne": 16.25 },
  { "id": 2, "nom": "Sami Trabelsi", "ville": "Tunis", "moyenne": 9.5 },
  { "id": 3, "nom": "Ines Gharbi", "ville": "Sfax", "moyenne": 13.75 },
  { "id": 4, "nom": "Youssef Karray", "ville": "Sousse", "moyenne": 11 },
  { "id": 5, "nom": "Salma Jaziri", "ville": "Monastir", "moyenne": 14.5 },
  { "id": 6, "nom": "Karim Mansour", "ville": "Tunis", "moyenne": 12.25 }
]
import { Component, computed, resource, signal } from '@angular/core';

interface Stagiaire {
  id: number;
  nom: string;
  ville: string;
  moyenne: number;
}

@Component({
  selector: 'app-ressource',
  template: `
    <select #choix (change)="ville.set(choix.value)">
      @for (v of ['Toutes', 'Sousse', 'Tunis', 'Sfax']; track v) { <option [value]="v">{{ v }}</option> }
    </select>
    <button (click)="stagiaires.reload()">Recharger</button>
    <button (click)="fichier.set(fichier() === 'stagiaires.json' ? 'absent.json' : 'stagiaires.json')">Changer de fichier</button>

    <p class="statut">statut : {{ stagiaires.status() }} – chargement : {{ stagiaires.isLoading() }}</p>
    @if (stagiaires.error()) {
      <p class="erreur">Erreur : {{ stagiaires.error()?.message }}</p>
    } @else if (stagiaires.hasValue()) {
      <ul>
        @for (s of stagiaires.value(); track s.id) { <li>{{ s.nom }} ({{ s.ville }}) – {{ s.moyenne }}</li> }
      </ul>
      <p>{{ nombre() }} stagiaire(s)</p>
    }
  `,
})
export class Ressource {
  protected readonly ville = signal('Toutes');
  protected readonly fichier = signal('stagiaires.json');

  protected readonly stagiaires = resource({
    params: () => ({ ville: this.ville(), fichier: this.fichier() }),
    loader: async ({ params, abortSignal }) => {
      await new Promise((ok) => setTimeout(ok, 600)); // simule un serveur lent
      const reponse = await fetch(params.fichier, { signal: abortSignal });
      if (!reponse.ok) throw new Error(`HTTP ${reponse.status} pour ${params.fichier}`);
      const liste: Stagiaire[] = await reponse.json();
      return params.ville === 'Toutes' ? liste : liste.filter((s) => s.ville === params.ville);
    },
  });

  protected readonly nombre = computed(() => (this.stagiaires.hasValue() ? this.stagiaires.value().length : 0));
}
statut : resolved – chargement : false          6 stagiaires
(ville → Sousse)       statut : loading – chargement : true      liste vide pendant 0,6 s
                       statut : resolved – chargement : false
                       Amira Ben Ali (Sousse) – 16.25 / Youssef Karray (Sousse) – 11 / 2 stagiaire(s)
(Recharger)            statut : reloading – chargement : true    l'ancienne liste reste affichée
(Changer de fichier)   statut : error – chargement : false
                       Erreur : HTTP 404 pour absent.json
  • Pendant un changement de paramètres (loading), la valeur est vide ; pendant un reload() (reloading), l'ancienne valeur reste disponible.
  • Le loader peut être n'importe quelle fonction async: fetch, un appel à une API, un calcul lourd. Pour les appels HTTP, Angular propose aussi httpResource(), qui utilise HttpClient (étudié dans un prochain chapitre).
  • Attention : en état d'erreur, lire value() lève une exception : ResourceValueError: Resource is currently in an error state (see Error.cause for details). Testez error() ou hasValue() avant, ou fournissez une defaultValue.
  • resource() est fait pour lire des données. Pour enregistrer (POST, PUT, DELETE), appelez directement votre service dans une méthode.
  1. Signals et RxJS : toSignal() et toObservable()

    • Beaucoup de bibliothèques Angular (le client HTTP, le routeur, les formulaires réactifs) exposent des observables RxJS. Le paquet @angular/core/rxjs-interop fait le pont : toSignal(observable) transforme un flux en signal, toObservable(signal) fait l'inverse.
import { Component, signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, interval, map } from 'rxjs';

@Component({
  selector: 'app-interop',
  template: `
    <p>Secondes écoulées : {{ secondes() }}</p>
    <input #champ (input)="recherche.set(champ.value)" placeholder="Rechercher" />
    <p>Saisie : « {{ recherche() }} » – recherche lancée pour : « {{ rechercheStable() }} »</p>
  `,
})
export class Interop {
  // Observable → signal : désabonnement automatique à la destruction du composant
  protected readonly secondes = toSignal(interval(1000).pipe(map((n) => n + 1)), { initialValue: 0 });

  // Signal → observable → opérateurs RxJS → signal
  protected readonly recherche = signal('');
  protected readonly rechercheStable = toSignal(toObservable(this.recherche).pipe(debounceTime(400)), {
    initialValue: '',
  });
}
Secondes écoulées : 10
(pendant la saisie de « angu »)     Saisie : « angu » – recherche lancée pour : « »
(0,4 s après la dernière frappe)   Saisie : « angu » – recherche lancée pour : « angu »
  • toSignal s'abonne immédiatement et se désabonne tout seul à la destruction du composant : plus besoin de subscribe ni d'unsubscribe. Comme effect, il doit être appelé dans un contexte d'injection (sinon NG0203: toSignal() can only be used within an injection context …).
  • initialValue donne la valeur à afficher avant la première émission ; sans elle, le signal vaut undefined au départ.
  • debounceTime(400) attend une pause de 400 ms dans la saisie : c'est le cas typique où les opérateurs RxJS restent plus pratiques que les signals.
  1. Les pièges à éviter

    • Modifier un objet ou un tableau sur place. this.liste().push('Sami') modifie le tableau sans prévenir le signal : un computed(() => this.liste().length) reste à 1 alors que la liste contient deux noms. Toujours créer une nouvelle valeur : update(l => [...l, 'Sami']), update(o => ({ ...o, age: 3 })).
    • Oublier les parenthèses dans le template : {{ compteur }} n'affiche pas la valeur (avertissement NG8109).
    • Écrire dans un computed: erreur NG0600. Un computed calcule, il ne modifie rien.
    • Un effet qui modifie le signal qu'il lit se relance lui-même : sans condition d'arrêt, la page boucle indéfiniment.
    • Utiliser effect pour dériver une valeur: préférez computed ou linkedSignal, plus simples, synchrones et sans risque de boucle.
    • Mélanger signals et propriétés ordinaires pour des données qui changent : une propriété modifiée dans un minuteur ou une promesse n'est pas affichée (voir « Le data binding dans Angular »).

Quel outil choisir parmi signal, computed, linkedSignal, resource, effect et toSignal

  1. Aide-mémoire

Aide-mémoire des signals Angular

  1. Activité 1 : un budget mensuel

    • Générer le composant act-signals/budget qui suit les dépenses d'un mois. L'état tient en deux signals : budget (1200 DT) et depenses (un tableau de { id, libelle, montant, categorie }, avec un loyer de 650 DT et des courses de 180 DT). Tout le reste est calculé avec computed:
      • le total dépensé, le reste (en rouge s'il est négatif) et le pourcentage utilisé, affiché dans une jauge qui passe au rouge à partir de 90 % ;
      • la plus grosse dépense, mise en gras dans la liste ;
      • le total par catégorie (Logement, Courses, Transport, Loisirs), sans les catégories vides.
    • Un formulaire ajoute une dépense (libellé, montant, catégorie) et chaque ligne a un bouton ✕ pour la supprimer. Les ajouts et suppressions utilisent update() avec un nouveau tableau.

Résultat attendu de l'activité 1 avec un budget de 1100 DT

  • Ajouter Essence (120), Cinéma (45) et Électricité (95), puis passer le budget à 1100 DT : vérifier « 1 090 DT (99 %) – reste : 10 DT ». Supprimer le loyer : quelle dépense passe en gras ?
  • Remplacer l'ajout par this.depenses().push(…): la liste et le total se mettent-ils à jour ? Expliquer.
  1. Activité 2 : des préférences enregistrées

    • Générer le composant act-signals/preferences: un signal prefs de type { theme, taille, langue }, lu au démarrage depuis localStorage (avec des valeurs par défaut si rien n'est enregistré). Le panneau :
      • propose une case « Thème sombre », un curseur de taille de texte (12 à 22 px) et une liste de langues (français, arabe, anglais) ; chaque réglage appelle modifier(changement), qui fait prefs.update(p => ({ ...p, ...changement }));
      • affiche « Bonjour / مرحبا / Hello, Amira ! » grâce à un computed, et passe en écriture de droite à gauche en arabe ([attr.dir]) ;
      • enregistre les préférences dans localStorage avec un effet, et met à jour le titre de l'onglet avec un second effet;
      • a un bouton Réinitialiser qui remet les valeurs par défaut.

Résultat attendu de l'activité 2 : thème sombre, 19 px, en arabe

  • Choisir le thème sombre, 19 px et l'arabe, puis recharger la page: les réglages sont-ils conservés ? Quelle ligne de code les relit ?
  • Pourquoi l'accès à localStorage est-il entouré d'un try … catch?
  • Pourquoi utiliser effect ici, alors qu'on le déconseille pour calculer des valeurs ?
  1. Activité 3 : la réservation d'une salle

    • Générer le composant act-signals/reservation-salle. Trois bâtiments contiennent des salles ({ nom, capacite, equipee }). Utiliser :
      • un signal batiment et une case projecteur; un computed salles qui ne garde que les salles équipées si la case est cochée ;
      • un linkedSignal salle qui revient à la première salle disponible quand la liste change, et que l'utilisateur peut changer en cliquant sur une salle ;
      • un linkedSignal participants (forme longue) qui conserve le nombre choisi au curseur, plafonné par la capacité de la nouvelle salle ;
      • un résumé « salle (bâtiment) réservée pour N personne(s), avec projecteur ».

Résultat attendu de l'activité 3 : salle B-204 pour 18 personnes

  • Régler 25 participants dans A-101, puis passer au bâtiment B : combien de participants pour B-Amphi ? Puis pour B-204 ? Expliquer avec precedent.value.
  • Cocher « Projecteur obligatoire » dans le bâtiment A : quelles salles restent ? Que se passerait-il si aucune salle ne convenait ?
  1. Activité 4 : un annuaire avec resource()

    • Générer le composant act-signals/annuaire qui charge stagiaires.json avec resource() (délai simulé de 500 ms) :
      • un champ de recherche par nom : c'est le seul paramètre de la resource, qui renvoie les stagiaires dont le nom contient le texte ;
      • un curseur « Moyenne ≥ N » appliqué par un computed sur value(), sans recharger les données ; les résultats sont triés par moyenne décroissante ;
      • un texte d'état « Chargement… » / « N résultat(s) » / « Erreur », un tableau estompé pendant le chargement, un bouton Actualiser (reload()) désactivé pendant le chargement, et un message d'erreur avec un bouton Réessayer;
      • defaultValue: [], pour que value() soit toujours un tableau.

Résultat attendu de l'activité 4 avec une moyenne minimale de 12

  • Pourquoi le curseur de moyenne agit-il instantanément, alors que la recherche par nom affiche « Chargement… » ? Que se passerait-il si minimum() était lu dans params?
  • Taper rapidement « ka » : combien de chargements sont lancés ? Combien vont jusqu'au bout ? Quel paramètre du loader le permet ?
  1. Activité 5 : un chronomètre

    • Générer le composant act-signals/chronometre avec trois signals : enMarche, ecoule (en centièmes de seconde) et tours (tableau des durées de chaque tour) :
      • un effet démarre un setInterval quand enMarche() est vrai, et l'arrête avec onCleanup; le temps est calculé avec Date.now() pour rester exact ;
      • l'affichage mm:ss.cc est un computed; les boutons sont Démarrer / Pause / Reprendre, Tour (actif seulement en marche) et Réinitialiser (seulement à l'arrêt) ;
      • la liste des tours met en vert le meilleur tour, et affiche le meilleur tour et la moyenne (deux computed) dès qu'il y a deux tours.

Résultat attendu de l'activité 5 après trois tours

  • Supprimer onCleanup(() => clearInterval(id)), puis faire plusieurs pauses et reprises : que constatez-vous ? Pourquoi ?
  • Pourquoi ne pas simplement ajouter 3 centièmes à chaque appel de setInterval?
  • Faire un commit après chaque activité, puis un push : git commit -m "Signals : activité N".
  1. Erreurs fréquentes

Message ou symptôme Cause Solution
NG0600: Writing to signals is not allowed in a `computed`. Signal: 'journal'. (console) set() ou update() dans un computed Un computed calcule seulement ; agir dans une méthode ou un effect
NG0203: effect() can only be used within an injection context … (console) effect() créé dans une méthode Le créer dans le constructeur ou l'initialisation d'une propriété
NG0203: toSignal() can only be used within an injection context … (console) toSignal() appelé dans une méthode L'appeler dans l'initialisation d'une propriété
TS2339: Property 'set' does not exist on type 'Signal<number>'. set() sur un computed ou un signal en lecture seule Modifier la source, ou utiliser linkedSignal()
TS2365: Operator '+' cannot be applied to types 'WritableSignal<number>' and 'number'. Signal utilisé sans parenthèses dans le code this.compteur() + 1
NG8109: compteur is a function and should be invoked: compteur() (avertissement) Signal sans parenthèses dans le template {{ compteur() }}
ResourceValueError: Resource is currently in an error state … (console) value() lu alors que la resource est en erreur Tester error() / hasValue(), ou defaultValue
Un computed ou un total ne se met plus à jour Tableau ou objet modifié sur place (push, o.x = …) update() avec une nouvelle valeur ([...l, x], { ...o, x })
Valeur jamais mise à jour malgré set() Option equal qui considère les valeurs comme identiques Revoir la fonction equal
L'effet se relance sans fin L'effet écrit dans un signal qu'il lit Lire avec untracked(), ou remplacer par computed / linkedSignal
Le chronomètre continue de tourner pendant la pause setInterval dans un effet sans onCleanup: l'ancien minuteur n'est jamais arrêté onCleanup(() => clearInterval(id))
Un effet se relance quand un signal sans rapport change Signal lu dans l'effet, donc devenu dépendance Le lire avec untracked()
  1. À retenir

    • Un signal contient une valeur et prévient ceux qui l'ont lue : c'est ce qui permet à Angular 22 de mettre à jour exactement ce qui doit l'être.
    • signal() pour l'état, lu avec s(), modifié avec set() / update() en créant toujours une nouvelle valeur pour les objets et les tableaux.
    • computed() pour les valeurs dérivées (mémorisées, paresseuses, en lecture seule) ; linkedSignal() pour une valeur dérivée que l'utilisateur peut modifier.
    • effect() pour agir sur le monde extérieur, créé dans un contexte d'injection, avec onCleanup et untracked si besoin ; jamais pour calculer.
    • resource() pour les données asynchrones : value(), status(), isLoading(), error(), reload().
    • toSignal() et toObservable() relient les signals à RxJS.
Un signal pour chaque donnée qui change, un computed pour chaque donnée qui se déduit, un effet seulement pour parler au monde extérieur : avec ces trois réflexes, votre application reste simple, rapide et prévisible.