Angular

Bases

Composants standalone, templates, liaisons, directives, pipes, cycle de vie.

Vérifié en septembre 2026 · Angular 22.1 · environ 15 min

Un composant Angular est une classe TypeScript, un template HTML qui décrit son rendu et un sélecteur qui dit où l'instancier. Le reste en découle : le template lit l'état de la classe par des liaisons, décide de sa structure par des blocs de flux de contrôle, reçoit et émet des données par des entrées et des sorties ; directives et pipes étendent ce qu'il sait faire, et le cycle de vie dit à quel moment le code de la classe s'exécute. En Angular 22, les défauts qui déterminent la façon d'écrire tout cela sont les composants standalone, la stratégie OnPush et les API fondées sur les signals. Les signals ont leur propre cours, Signals ; ici, ils sont simplement l'état que lisent les templates.

Le composant standalone

Un composant standalone déclare lui-même ses dépendances de template : le tableau imports de son décorateur liste les composants, directives et pipes qu'il utilise, et rien d'autre n'y est visible. C'est le défaut depuis Angular 19 ; standalone: false ne sert plus qu'aux composants encore déclarés dans un NgModule. Le module devient inutile parce que la portée de compilation qu'il portait pour un groupe de composants est désormais celle de chaque composant.

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

@Component({
  selector: 'app-pastille',
  template: `<span class="pastille">{{ texte() }}</span>`,
  // Styles encapsules : ils ne touchent que le template de ce composant.
  styles: `.pastille { border-radius: 1em; padding: 0 0.5em; background: #eee; }`,
})
export class Pastille {
  readonly texte = input.required<string>();
}

@Component({
  // La balise qui instanciera le composant dans un autre template.
  selector: 'app-fiche-tache',
  // Tout ce que le template utilise hors du HTML se declare ici : composants,
  // directives, pipes. C'est la seule portee qui existe.
  imports: [Pastille, DatePipe],
  // Ni standalone: true (le defaut depuis Angular 19), ni OnPush (le defaut
  // depuis Angular 22) : les ecrire ne changerait rien.
  template: `
    <h3>{{ titre() }}</h3>
    <app-pastille [texte]="statut()" />
    <p>Creee le {{ creation | date: 'dd/MM/yyyy' }}</p>
  `,
})
export class FicheTache {
  // protected : lisible par le template, absent de l'API publique de la classe.
  protected readonly titre = signal('Relire le contrat');
  protected readonly statut = signal('en cours');
  protected readonly creation = new Date(2026, 8, 25);
}

// Rendu : <h3>Relire le contrat</h3>, la pastille « en cours »,
// puis <p>Creee le 25/09/2026</p>.

La portée étant locale, un oubli se voit à la compilation, pas à l'exécution. Retirer Pastille des imports donne NG8001, 'app-pastille' is not a known element ; retirer DatePipe donne NG8004, No pipe found with name 'date'. L'inverse est signalé aussi : un import que le template n'utilise plus produit l'avertissement NG8113. L'application démarre par bootstrapApplication(App, appConfig), où la configuration n'est qu'une liste de fournisseurs.

Deux défauts s'appliquent sans être écrits. OnPush ne revérifie un composant que si une de ses entrées reçoit une nouvelle valeur, si un événement est traité dans son template ou celui d'un descendant, si un signal qu'il lit est modifié, ou si markForCheck le demande. Et Zone.js a disparu des applications neuves depuis Angular 21. Le cours Signals explique ce que ces deux défauts imposent ; la règle pratique est qu'un état affiché est un signal.

Templates et flux de contrôle

Le template est du HTML augmenté d'expressions, de liaisons et de blocs. Les blocs @if, @for et @switch, stables depuis Angular 18, remplacent *ngIf, *ngFor et *ngSwitch : ils appartiennent au langage de template, ne s'importent pas, et le compilateur les vérifie comme du code. @if (x; as y) rétrécit le type de y comme un if TypeScript, et @let nomme une valeur pour la suite du template.

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

type Statut = 'a-faire' | 'en-cours' | 'terminee';

interface Tache {
  readonly id: number;
  readonly titre: string;
  readonly statut: Statut;
}

@Component({
  selector: 'app-liste-taches',
  template: `
    <!-- @let nomme une valeur pour la suite du template. -->
    @let restantes = nombreRestantes();

    @if (restantes === 0) {
      <p>Tout est fait.</p>
    } @else {
      <p>{{ restantes }} tache(s) restante(s)</p>
    }

    <!-- as nomme le resultat de la condition, avec son type retreci :
         enCours() rend Tache | undefined, tache est une Tache. -->
    @if (enCours(); as tache) {
      <p>En cours : {{ tache.titre }}</p>
    }

    <ul>
      @for (tache of taches(); track tache.id; let derniere = $last) {
        <li [class.derniere]="derniere">
          {{ tache.titre }} :
          <!-- @default never : chaque valeur de Statut doit avoir son @case. -->
          @switch (tache.statut) {
            @case ('a-faire') { a faire }
            @case ('en-cours') { en cours }
            @case ('terminee') { terminee }
            @default never;
          }
        </li>
      } @empty {
        <li>Aucune tache.</li>
      }
    </ul>
  `,
})
export class ListeTaches {
  protected readonly taches = signal<readonly Tache[]>([
    { id: 1, titre: 'Ecrire', statut: 'terminee' },
    { id: 2, titre: 'Relire', statut: 'en-cours' },
    { id: 3, titre: 'Publier', statut: 'a-faire' },
  ]);

  protected readonly nombreRestantes = computed(
    () => this.taches().filter((tache) => tache.statut !== 'terminee').length,
  );

  protected readonly enCours = computed(() =>
    this.taches().find((tache) => tache.statut === 'en-cours'),
  );
}

@switch compare par ===, sans chute d'un cas dans le suivant. @default never; demande au compilateur de prouver que tous les cas sont couverts : ajouter 'annulee' à Statut sans son @case fait échouer le build sur Type '"annulee"' is not assignable to type 'never'. La preuve repose sur le rétrécissement de TypeScript, qui ne s'applique pas à un appel : sur @switch (statut()), @default never; refuse même un switch complet. Une valeur de signal passe donc d'abord par @let.

track est obligatoire : sans lui, la compilation échoue sur @for loop must have a "track" expression. Il donne à chaque élément une clé qui le relie à sa portion de DOM. Quand la liste change, Angular compare les clés, déplace les vues dont la clé a bougé, crée celles des nouvelles clés et détruit celles des clés disparues. Une mauvaise clé n'altère pas ce qu'affichent les liaisons du template, puisqu'elles sont réévaluées, mais tout ce que la vue porte en propre — une saisie non liée, le focus, l'état d'un composant enfant ou d'une directive, comme le contrôle qu'un formControlName a relié (cours Formulaires) — reste sur la mauvaise ligne. track $index le montre sur une liste dont on retire un élément.

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

interface Tache {
  readonly id: number;
  readonly titre: string;
}

@Component({
  selector: 'app-notes-taches',
  template: `
    @for (tache of taches(); track $index) {
      <p>
        <label>{{ tache.titre }} <input placeholder="Note" /></label>
        <button type="button" (click)="retirer(tache.id)">Retirer</button>
      </p>
    }
  `,
})
export class NotesTaches {
  protected readonly taches = signal<readonly Tache[]>([
    { id: 1, titre: 'Ecrire' },
    { id: 2, titre: 'Relire' },
    { id: 3, titre: 'Publier' },
  ]);

  protected retirer(id: number): void {
    this.taches.update((taches) => taches.filter((tache) => tache.id !== id));
  }
}

// Saisir « urgent » dans la note de Relire, puis retirer Ecrire :
//   Relire  [      ]
//   Publier [urgent]
// La cle est la position. La vue de la position 0 est gardee et recoit
// Relire ; celle de la position 1, qui porte la saisie, recoit Publier ;
// celle de la position 2 est detruite. Les donnees ont suivi, le DOM non.

La clé juste est l'identité métier de l'élément. $index ne convient qu'à une liste qui ne bouge pas ; track tache, l'objet lui-même, tient tant que les objets ne sont pas recréés, mais une liste rechargée, dont chaque objet est neuf, verrait tout son DOM reconstruit, un coût que le cours Performance détaille.

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

interface Tache {
  readonly id: number;
  readonly titre: string;
}

@Component({
  selector: 'app-notes-taches',
  template: `
    @for (tache of taches(); track tache.id) {
      <p>
        <label>{{ tache.titre }} <input placeholder="Note" /></label>
        <button type="button" (click)="retirer(tache.id)">Retirer</button>
      </p>
    }
  `,
})
export class NotesTaches {
  protected readonly taches = signal<readonly Tache[]>([
    { id: 1, titre: 'Ecrire' },
    { id: 2, titre: 'Relire' },
    { id: 3, titre: 'Publier' },
  ]);

  protected retirer(id: number): void {
    this.taches.update((taches) => taches.filter((tache) => tache.id !== id));
  }
}

// Meme scenario :
//   Relire  [urgent]
//   Publier [      ]
// La cle 1 a disparu : seule la vue d'Ecrire est detruite, les deux autres
// sont conservees telles quelles, saisie comprise.

Les liaisons

Quatre formes relient le template à la classe, et chacune a un sens. L'interpolation {{ }} convertit une valeur en texte, échappé : du HTML dans la valeur s'affiche, il n'est pas interprété. La liaison de propriété [value] écrit une propriété du DOM ou une entrée de composant, avec son type ; [attr.x] écrit un attribut qui n'a pas de propriété correspondante, [class.x] et [style.x] une classe ou une propriété CSS, et null retire l'attribut ou le style. Les attributs ARIA se lient directement, [aria-label]. La liaison d'événement (input) pose un écouteur, reçoit l'événement dans $event et accepte des filtres de touche comme (keydown.escape). La quatrième, [(x)], combine les deux sens ; elle revient avec model().

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

@Component({
  selector: 'app-recherche-taches',
  template: `
    <!-- Interpolation : la valeur devient du texte, echappe. Un terme qui
         contient <b> s'affiche en toutes lettres, il n'est pas interprete. -->
    <h3>Recherche : {{ terme() || 'tout' }}</h3>

    <!-- [propriete] ecrit une propriete du DOM ; (evenement) pose un
         ecouteur, que keydown.escape restreint a une touche. $event est
         l'evenement ; sur un input, son target est type HTMLInputElement. -->
    <input
      type="search"
      [value]="terme()"
      (input)="terme.set($event.target.value)"
      (keydown.escape)="terme.set('')"
    />
    <button type="button" [disabled]="terme() === ''" (click)="terme.set('')">Effacer</button>

    <!-- Une classe, une propriete CSS, un attribut. null retire le style
         ou l'attribut. -->
    <p
      [class.vide]="resultats() === 0"
      [style.font-weight]="resultats() > 1 ? 'bold' : null"
      [attr.data-resultats]="resultats()"
    >
      {{ resultats() }} resultat(s)
    </p>
  `,
})
export class RechercheTaches {
  protected readonly terme = signal('');
  private readonly titres = ['Ecrire', 'Relire', 'Publier'];

  protected readonly resultats = computed(
    () => this.titres.filter((titre) => titre.toLowerCase().includes(this.terme().toLowerCase())).length,
  );
}

Depuis Angular 20.2, sur un élément sans contenu comme input, le compilateur type $event.target comme l'élément lui-même, ici un HTMLInputElement, et une propriété inexistante y est une erreur. Sur textarea ou select, target reste EventTarget | null, et $event.target.value ne compile pas. Une variable de référence, #zone puis zone.value, porte le type exact de l'élément et marche partout. Le piège classique tient à la différence entre attribut et propriété : une interpolation posée dans un attribut devient une liaison de propriété, mais sa valeur reste une chaîne.

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

@Component({
  selector: 'app-envoi',
  template: `
    <!-- Une interpolation dans un attribut devient une liaison de propriete,
         mais sa valeur est toujours une chaine. disabled recoit 'false',
         chaine non vide donc vraie : le bouton est desactive d'emblee. -->
    <button type="submit" disabled="{{ envoiEnCours() }}">Envoyer</button>
  `,
})
export class Envoi {
  protected readonly envoiEnCours = signal(false);
}

Le compilateur ne dit rien : c'est la propriété disabled du DOM qui convertit la chaîne 'false' en true. Les crochets passent la valeur telle qu'elle est.

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

@Component({
  selector: 'app-envoi',
  template: `
    <!-- Les crochets passent la valeur avec son type : le booleen false. -->
    <button type="submit" [disabled]="envoiEnCours()">Envoyer</button>
  `,
})
export class Envoi {
  protected readonly envoiEnCours = signal(false);
}

input(), output() et model()

input() déclare une entrée sous forme de signal en lecture seule : le parent l'écrit par une liaison de propriété, le composant la lit en l'appelant et peut en dériver des computed. input.required() rend la liaison obligatoire, ce que le compilateur vérifie : omettre [max] donne NG8008, Required input 'max' from component NoteEtoiles must be specified. L'option transform convertit la valeur reçue, et booleanAttribute permet d'écrire l'attribut nu comme en HTML. output() déclare un événement que le composant déclenche par emit et que le parent écoute entre parenthèses ; la valeur émise y devient $event.

model() couvre la donnée qui circule dans les deux sens : c'est une entrée que le composant peut écrire, et chaque set qui change la valeur émet la sortie implicite noteChange. C'est tout ce que [(note)] demande. Côté parent, on passe le signal lui-même, sans parenthèses, et il reste à jour.

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

@Component({
  selector: 'app-note-etoiles',
  template: `
    @for (n of etoiles(); track n) {
      <button
        type="button"
        [aria-label]="n + ' sur ' + max()"
        [disabled]="lectureSeule()"
        (click)="choisir(n)"
      >
        {{ n <= note() ? '★' : '☆' }}
      </button>
    }
  `,
})
export class NoteEtoiles {
  // Obligatoire : l'omettre dans un template parent ne compile pas.
  readonly max = input.required<number>();

  // L'attribut nu, <app-note-etoiles lectureSeule />, vaut true, comme un
  // attribut booleen HTML.
  readonly lectureSeule = input(false, { transform: booleanAttribute });

  // Une entree que le composant peut ecrire : chaque set qui change la
  // valeur emet noteChange.
  readonly note = model(0);

  // Une sortie : le parent l'ecoute avec (validee), et $event vaut n.
  readonly validee = output<number>();

  protected readonly etoiles = computed(() =>
    Array.from({ length: this.max() }, (_, i) => i + 1),
  );

  protected choisir(n: number): void {
    this.note.set(n);
    this.validee.emit(n);
  }
}

@Component({
  selector: 'app-avis',
  imports: [NoteEtoiles],
  template: `
    <!-- [(note)]="note" vaut [note]="note()" plus (noteChange)="note.set($event)". -->
    <app-note-etoiles [max]="5" [(note)]="note" (validee)="enregistrer($event)" />
    <app-note-etoiles [max]="5" [note]="note()" lectureSeule />
    <p>{{ message() }}</p>
  `,
})
export class Avis {
  protected readonly note = signal(3);
  protected readonly message = signal('');

  protected enregistrer(note: number): void {
    this.message.set(`Note ${note} enregistree`);
  }
}

// Clic sur la quatrieme etoile du premier composant : note() vaut 4 dans
// Avis, le second composant affiche quatre etoiles pleines, et le message
// devient « Note 4 enregistree ».

Les décorateurs @Input() et @Output() restent pris en charge. Les fonctions ont pour elles un type exact — input.required<number>() se lit en number, pas en number | undefined — et une valeur réactive, qu'un computed suit sans ngOnChanges. Une entrée n'a pas de set (Property 'set' does not exist on type 'InputSignal<number>') : si le composant doit modifier ce qu'il reçoit, c'est un model(), ou un linkedSignal quand la modification doit rester locale.

Les directives d'attribut

Une directive d'attribut ajoute un comportement à un élément existant, sans template à elle. Elle se déclare par @Directive avec un sélecteur d'attribut, et agit sur son élément hôte par la propriété host : liaisons de propriété, de classe, de style et écouteurs, dans la syntaxe d'un template. Les décorateurs @HostBinding et @HostListener font la même chose membre par membre ; la documentation d'Angular demande de toujours leur préférer host, et précise qu'ils n'existent plus que pour la compatibilité. Avec les signals, la raison devient concrète : @HostBinding lie la valeur d'une propriété, pas une expression, et quand la propriété est un signal, cette valeur est une fonction.

import { Directive, HostBinding, HostListener, signal } from '@angular/core';

@Directive({ selector: '[appSurbrillance]' })
export class Surbrillance {
  // HostBinding lie la valeur de la propriete, pas une expression. Cette
  // valeur est le signal lui-meme, une fonction, donc toujours vraie : la
  // classe est posee des le premier rendu et ne part jamais.
  @HostBinding('class.surbrillance')
  protected readonly active = signal(false);

  @HostListener('mouseenter')
  protected entrer(): void {
    this.active.set(true);
  }

  @HostListener('mouseleave')
  protected sortir(): void {
    this.active.set(false);
  }
}

Aucun diagnostic ne le signale, pas même avec la vérification des liaisons d'hôte, active par défaut : une liaison de classe accepte n'importe quelle valeur. Dans host, la liaison est une expression, lue comme dans un template : on appelle le signal, et cette lecture abonne la vue. Tout le contrat de la directive avec son hôte tient dans un seul objet.

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

@Directive({
  selector: '[appSurbrillance]',
  // Les liaisons sur l'element hote, dans la syntaxe d'un template.
  host: {
    '[class.surbrillance]': 'active()',
    '[style.background-color]': "active() ? couleur() : null",
    '(mouseenter)': 'active.set(true)',
    '(mouseleave)': 'active.set(false)',
  },
})
export class Surbrillance {
  // L'entree porte le nom du selecteur : appSurbrillance="lightblue".
  readonly couleur = input('gold', { alias: 'appSurbrillance' });
  protected readonly active = signal(false);
}

@Component({
  selector: 'app-liste-courte',
  imports: [Surbrillance],
  template: `
    <ul>
      <li appSurbrillance="lightblue">Ecrire</li>
      <li appSurbrillance="gold">Relire</li>
    </ul>
  `,
})
export class ListeCourte {}

Les pipes

Un pipe transforme une valeur pour l'affichage, comme date dans le premier exemple. @angular/common en fournit une douzaine — date, currency, number, percent, uppercase, async… —, qui s'importent un à un. Un pipe personnalisé est une classe décorée par @Pipe, dont la méthode transform reçoit la valeur puis les paramètres.

Un pipe est pur par défaut : Angular ne rappelle transform que si la valeur ou un paramètre a changé, comparés par ===, donc par référence pour un tableau. C'est ce qui le rend moins cher qu'une méthode appelée dans le template, réévaluée à chaque vérification de la vue. La contrepartie : un tableau modifié en place garde sa référence, et le pipe rend l'ancien résultat. La correction tentante consiste à déclarer le pipe impur.

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

interface Tache {
  readonly titre: string;
  readonly terminee: boolean;
}

@Pipe({ name: 'restantes', pure: false })
export class RestantesPipe implements PipeTransform {
  transform(taches: readonly Tache[]): number {
    return taches.filter((tache) => !tache.terminee).length;
  }
}

@Component({
  selector: 'app-tableau-taches',
  imports: [RestantesPipe],
  template: `
    <p>{{ taches | restantes }} tache(s) restante(s)</p>
    <input #champ [value]="titre()" (input)="titre.set(champ.value)" />
    <button type="button" [disabled]="titre() === ''" (click)="ajouter()">Ajouter</button>
  `,
})
export class TableauTaches {
  protected readonly titre = signal('');

  // Modifie en place par push : la reference du tableau ne change jamais.
  // Avec un pipe pur, le compte resterait a 1 apres chaque ajout.
  protected readonly taches: Tache[] = [{ titre: 'Ecrire', terminee: false }];

  protected ajouter(): void {
    this.taches.push({ titre: this.titre(), terminee: false });
    this.titre.set('');
  }
}

pure: false rétablit le bon compte en supprimant la mémoïsation : transform s'exécute à chaque vérification de la vue, quelle qu'en soit la cause. Ici, chaque frappe dans le champ modifie titre, la vue est revérifiée, et la liste entière est refiltrée : trois frappes, trois filtrages, sans qu'aucune tâche ait changé. Le coût croît avec la taille de la liste, la fréquence des vérifications et le nombre d'endroits où le pipe apparaît. La correction se fait en amont : remplacer le tableau au lieu de le muter, et garder le pipe pur.

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

interface Tache {
  readonly titre: string;
  readonly terminee: boolean;
}

// Pur par defaut : transform n'est rappele que pour une nouvelle reference.
@Pipe({ name: 'restantes' })
export class RestantesPipe implements PipeTransform {
  transform(taches: readonly Tache[]): number {
    return taches.filter((tache) => !tache.terminee).length;
  }
}

@Component({
  selector: 'app-tableau-taches',
  imports: [RestantesPipe],
  template: `
    <p>{{ taches() | restantes }} tache(s) restante(s)</p>
    <input #champ [value]="titre()" (input)="titre.set(champ.value)" />
    <button type="button" [disabled]="titre() === ''" (click)="ajouter()">Ajouter</button>
  `,
})
export class TableauTaches {
  protected readonly titre = signal('');
  protected readonly taches = signal<readonly Tache[]>([{ titre: 'Ecrire', terminee: false }]);

  protected ajouter(): void {
    // Un nouveau tableau : nouvelle reference, le pipe recalcule une fois.
    this.taches.update((taches) => [...taches, { titre: this.titre(), terminee: false }]);
    this.titre.set('');
  }
}

Un computed ferait le même travail, avec la même mémoïsation ; le pipe se justifie quand la transformation sert dans plusieurs templates. Parmi les pipes fournis, async, json, keyvalue et slice sont impurs : async parce qu'il doit relire une valeur qui arrive sans que la référence de l'observable change, les autres pour refléter une modification faite à l'intérieur d'un objet ou d'un tableau.

Le cycle de vie en Angular 22

Angular construit le composant, affecte ses entrées, vérifie son template, écrit le DOM, puis le revérifie à chaque changement jusqu'à le détruire. ngOnInit, ngOnChanges, ngAfterViewInit et ngOnDestroy s'inscrivent à ces étapes et existent toujours. Ce que les signals ont changé, c'est combien on en a besoin : la plupart servaient à attendre qu'une valeur existe ou à réagir quand elle change, et un signal sait déjà faire l'un et l'autre.

Le constructeur s'exécute avant l'affectation des entrées. Lire une entrée requise dans son corps ne compile pas (NG8118, `taches` is a required `input` and does not have a value in this context) ; passer par une méthode trompe le compilateur mais lève NG0950 à l'exécution, Input "taches" is required but no value is available yet. Le réflexe d'avant, calculer dans ngOnInit, compile et s'exécute, mais une seule fois.

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

interface Tache {
  readonly titre: string;
  readonly terminee: boolean;
}

@Component({
  selector: 'app-avancement',
  template: `<p>{{ terminees }} / {{ taches().length }} terminees</p>`,
})
export class Avancement implements OnInit {
  readonly taches = input.required<readonly Tache[]>();
  protected terminees = 0;

  ngOnInit(): void {
    // Les entrees ont leur valeur, le calcul passe. Mais une seule fois.
    this.terminees = this.taches().filter((tache) => tache.terminee).length;
  }
}

// Le parent passe deux taches dont une terminee : « 1 / 2 terminees ».
// Il passe ensuite trois taches terminees : « 1 / 3 terminees ». Le
// template relit taches(), qui a change, et le champ, qui n'a pas bouge.

Un computed règle les deux problèmes : paresseux, il n'est évalué que lorsque le template le lit, entrées affectées ; réactif, il suit chaque nouvelle valeur. Le constructeur garde son rôle de contexte d'injection : DestroyRef s'y obtient, afterNextRender s'y enregistre. Ce dernier exécute son rappel une fois, après que le DOM de toute l'application a été écrit, et jamais au rendu serveur : c'est la place du focus, d'une mesure, d'une bibliothèque qui manipule le DOM. DestroyRef.onDestroy remplace ngOnDestroy en rapprochant le nettoyage de ce qu'il nettoie, et se transmet à une fonction qui gère sa propre fin.

import {
  Component,
  DestroyRef,
  ElementRef,
  OnInit,
  afterNextRender,
  computed,
  inject,
  input,
  signal,
  viewChild,
} from '@angular/core';

interface Tache {
  readonly titre: string;
  readonly terminee: boolean;
}

@Component({
  selector: 'app-avancement',
  host: { '[class.compact]': 'compact()' },
  template: `
    <p>{{ terminees() }} / {{ taches().length }} terminees</p>
    <input #champ placeholder="Nouvelle tache" />
  `,
})
export class Avancement implements OnInit {
  readonly taches = input.required<readonly Tache[]>();

  // Remplace ngOnChanges : recalcule a chaque nouvelle liste, et n'est
  // evalue qu'a la premiere lecture, quand l'entree a sa valeur.
  protected readonly terminees = computed(
    () => this.taches().filter((tache) => tache.terminee).length,
  );

  protected readonly compact = signal(false);
  private readonly champ = viewChild.required<ElementRef<HTMLInputElement>>('champ');

  constructor() {
    // Le constructeur est le contexte d'injection : inject, afterNextRender
    // et DestroyRef s'y obtiennent.
    const hote = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
    const destruction = inject(DestroyRef);

    // Une fois, apres l'ecriture du DOM ; jamais au rendu serveur.
    afterNextRender(() => {
      this.champ().nativeElement.focus();

      const observateur = new ResizeObserver(([entree]) =>
        this.compact.set(entree.contentRect.width < 320),
      );
      observateur.observe(hote);

      // Le nettoyage s'ecrit a cote de ce qu'il nettoie.
      destruction.onDestroy(() => observateur.disconnect());
    });
  }

  ngOnInit(): void {
    // Premier point ou les entrees ont leur valeur : utile pour une action
    // ponctuelle, pas pour un etat qui doit suivre l'entree.
    console.info(`Avancement ouvert sur ${this.taches().length} taches`);
  }
}
BesoinAvant les signalsEn Angular 22
Dériver un état d'une entréengOnChanges et un champcomputed
Agir une fois, entrées connuesngOnInitngOnInit, toujours
Lire un élément du template@ViewChild et ngAfterViewInitviewChild(), un signal
Toucher le DOM rendungAfterViewInitafterNextRender, afterEveryRender
Effet de bord quand un état changengOnChanges, souscription manuelleeffect (cours Signals)
NettoyerngOnDestroyDestroyRef.onDestroy
Ce cours vous a servi ? Offrir un café Signaler une erreur