Leçon 07 · Phase 1 — Réactivité moderne Signals · ~18 min

Signals #2 : effect(), input() & output()

La leçon 06 t'a donné la valeur (signal) et le dérivé pur (computed). Il manque deux choses pour un vrai composant : faire quelque chose quand une valeur change (logguer, persister, appeler une API) — c'est effect() — et recevoir/émettre des données entre composants parent et enfant — ce sont les signals input() et output().

Pourquoi ça compte pour Alliance Un écran ERP n'est jamais un seul composant : un tableau parent passe un poste à une ligne enfant (input), l'enfant remonte un clic « éditer » (output). Et certaines réactions sortent du calcul pur : sauvegarder un filtre dans le localStorage, synchroniser l'URL, logguer une alerte — c'est le territoire d'effect(). Sans ces trois briques, tu sais faire un composant isolé, pas une application.

1. effect() — réagir à un changement

Un computed calcule et renvoie une valeur (pur). Un effect ne renvoie rien : il exécute une action à chaque fois qu'un signal qu'il lit change. C'est le bon endroit pour les effets de bord.

Cas concret : on reprend le champ de note avec compteur de l'exemple précédent, et on veut un auto-save du brouillon — comme dans Gmail. L'utilisateur tape, ça se sauve tout seul ; il rafraîchit la page, son texte est encore là. Aucun bouton, aucun timer à câbler.

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

@Component({
  selector: 'app-note',
  standalone: true,
  template: `
    <textarea [value]="texte()"
              (input)="texte.set($any($event.target).value)"></textarea>
  `,
})
export class NoteComponent {
  private readonly CLE = 'brouillon-note';

  // au démarrage : on RESTAURE le brouillon laissé la dernière fois
  texte = signal(localStorage.getItem(this.CLE) ?? '');

  constructor() {
    // AUTO-SAVE : se rejoue à chaque frappe, sans rien câbler d'autre
    effect(() => {
      localStorage.setItem(this.CLE, this.texte());
    });
  }
}

Comme pour computed, les dépendances sont détectées automatiquement : l'effect lit texte(), donc il se rejoue à chaque frappe. Il tourne aussi une première fois au montage. À noter : on le crée dans le constructor (contexte d'injection) ; il se nettoie tout seul quand le composant est détruit. Compare au monde « propriétés nues » : il aurait fallu un handler qui, à chaque input, recompte et ré-écrit dans localStorage à la main. Ici tu déclares la dépendance une fois.

computed vs effect — la frontière à ne jamais franchir Question à te poser : « je veux une valeur, ou je veux faire une action ? » Une valeur dérivée → computed (pur, renvoie, mémoïsé). Une action sur le monde extérieur (DOM hors template, localStorage, log, analytics, API) → effect. Règle d'or de revue : un effect ne doit pas servir à calculer un autre signal (écrire un .set() dedans pour dériver une valeur). Si tu fais ça, c'est presque toujours un computed déguisé, et c'est un drapeau rouge.

2. input() — recevoir une donnée du parent

Un composant enfant déclare ce qu'il accepte du parent avec input(). Le résultat est un signal en lecture seule : tu le lis avec (), exactement comme les autres.

Rendons ce champ réutilisable : un <app-champ-texte> qu'on pose n'importe où, en lui passant son libellé et sa limite. Ce qui change d'un usage à l'autre devient des input().

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

@Component({
  selector: 'app-champ-texte',
  standalone: true,
  template: `
    <label>{{ label() }}</label>
    <textarea [value]="texte()"
              (input)="texte.set($any($event.target).value)"></textarea>
    <small [class.ko]="trop()">{{ restants() }} restants</small>
  `,
})
export class ChampTexteComponent {
  label = input.required<string>();  // requis : le parent DOIT le fournir
  max   = input(280);              // optionnel, valeur par défaut

  texte    = signal('');
  restants = computed(() => this.max() - this.texte().length); // un input nourrit un computed
  trop     = computed(() => this.restants() < 0);
}

Côté parent, on alimente l'input via le property binding de la leçon 04. Note la nuance : sans crochets c'est une chaîne littérale, avec crochets c'est une expression.

<app-champ-texte label="Commentaire d'alerte" [max]="280" />
Analogie Un input(), c'est un paramètre du composant : comme les arguments d'un constructeur ou les propriétés publiques d'une classe que l'appelant renseigne. input.required<T>() = un paramètre obligatoire (oubli = erreur de compilation). La nouveauté vs l'ancien @Input() : ici l'input est un signal, donc tu peux le brancher direct dans un computed ou un effect — la réactivité traverse la frontière parent → enfant sans glu.

3. output() — émettre vers le parent

L'enfant ne modifie jamais directement le parent. Il émet un événement via output(), et le parent décide quoi en faire. Sens : enfant → parent (l'event binding de la leçon 04, côté composant).

On ajoute un bouton « Enregistrer » au champ. Quand on clique, l'enfant n'écrit rien lui-même : il émet le texte saisi et laisse le parent décider.

// dans ChampTexteComponent — on ajoute l'output et le bouton
valide = output<string>();           // émet le texte saisi

valider() {
  this.valide.emit(this.texte()); // on remonte la donnée au parent
  this.texte.set('');            // puis on vide le champ
}
// template : <button [disabled]="trop() || texte().length === 0"
//                    (click)="valider()">Enregistrer</button>

Côté parent, on écoute l'output comme un événement et on collecte la note dans la liste :

@Component({
  selector: 'app-fiche-poste',
  standalone: true,
  imports: [ChampTexteComponent],
  template: `
    <app-champ-texte label="Commentaire d'alerte"
                     (valide)="ajouter($event)" />
    <ul>
      @for (note of notes(); track $index) { <li>{{ note }}</li> }
    </ul>
  `,
})
export class FichePosteComponent {
  notes = signal<string[]>([]);

  ajouter(texte: string) {
    this.notes.update(liste => [...liste, texte]); // événement reçu → état mis à jour
  }
}

Le parent reçoit la donnée émise dans $event. C'est le pendant exact de l'input : [ ] fait descendre la donnée, ( ) fait remonter l'événement. Données vers le bas, événements vers le haut : c'est le contrat de communication entre composants. Remarque comme tout se recolle : l'input nourrit un computed (leçon 06), l'output déclenche un update sur un signal, et le @for (leçon 05) réaffiche la liste — sans une ligne de synchro manuelle.

4. Les quatre primitives signals d'un coup d'œil

PrimitiveRôleSens
signal(v)valeur source, modifiableinterne
computed(fn)valeur dérivée pure (lecture seule)interne
effect(fn)action / effet de bord au changementvers l'extérieur
input() / output()recevoir / émettreparent ↔ enfant
Œil de tech lead (1) N'utilise effect() que pour de vrais effets de bord. Dériver une valeur dans un effect (avec un .set()) au lieu d'un computed = le bug d'architecture le plus fréquent en revue signals (recalculs en cascade, ordre imprévisible). (2) Un input() est en lecture seule : l'enfant ne réécrit jamais dedans, il émet via un output et laisse le parent trancher. (3) Préfère input.required<T>() quand la donnée est indispensable : l'oubli est attrapé à la compilation, pas en prod. (4) Nomme les output par l'intention métier (editer, supprimer), pas par le mécanisme (onClick).

5. Vérifie ta compréhension

Tu veux sauvegarder un filtre dans localStorage dès qu'il change. Tu utilises ?
Un composant enfant doit prévenir le parent qu'on a cliqué « éditer ». Quel outil ?
Pourquoi est-ce un drapeau rouge d'appeler autreSignal.set(...) dans un effect() ?
À retenir Quatre primitives : signal (valeur source), computed (valeur dérivée pure), effect (action / effet de bord rejouée au changement, dépendances auto-détectées, créée dans le constructor), et la paire input() / output() (recevoir du parent / émettre vers lui). Contrat de communication : données vers le bas ([input]), événements vers le haut ((output)). Réflexe de lead : valeur → computed, action → effect (jamais un .set() dérivé dedans), input.required pour l'indispensable.
Source primaire à lire (10 min)

Angular — Signals (sections Effects) puis input() et output(). La page Components montre le couple input/output en contexte.

Je suis ton prof — pose-moi des questions. Oui, les signals paraissent verbeux au début — le déclic vient quand tu vois un cas où ils suppriment du code (l'auto-save ci-dessus en est un). Tu veux qu'on assemble la fiche poste complète (champ réutilisable + liste de notes + auto-save) en un seul composant qui tourne, ou que je te montre un effect qui synchronise un filtre avec l'URL ? Demande-moi, ici, maintenant.
◀ 06 · Signals #1 : signal() & computed() Suivant : 08 · RxJS #1 : c'est quoi un Observable ▶