Maîtrisez viewChild(), viewChildren(), contentChild() et contentChildren() d'Angular 19 : requêtes signal-based sans décorateurs, avec effect() et computed().
@ViewChild vs viewChild() : le changement de paradigme
Depuis Angular 1 jusqu'à Angular 16, accéder à un élément du template depuis le TypeScript passait obligatoirement par les décorateurs @ViewChild et @ContentChild. Ces décorateurs fonctionnent, mais ils introduisent une contrainte importante : les valeurs ne sont disponibles qu'à partir de AfterViewInit.
Voici un exemple classique — un lecteur audio où on contrôle l'élément <audio> natif depuis le composant :
// ❌ Ancienne approche — décorateurs + cycle de vie AfterViewInit
import { Component, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
@Component({
selector: 'app-lecteur-audio',
template: `
<audio #lecteur src="/assets/podcast-episode1.mp3"></audio>
<button (click)="lire()">Lire</button>
<button (click)="pause()">Pause</button>
`
})
export class LecteurAudioComponent implements AfterViewInit {
// @ViewChild — la valeur est undefined jusqu'à AfterViewInit
@ViewChild('lecteur') lecteurRef!: ElementRef<HTMLAudioElement>;
ngAfterViewInit() {
// Seulement ICI la valeur est disponible — pas avant
console.log('Durée :', this.lecteurRef.nativeElement.duration);
}
lire() { this.lecteurRef.nativeElement.play(); }
pause() { this.lecteurRef.nativeElement.pause(); }
}
Ce code a deux problèmes : il force l'implémentation de AfterViewInit, et si on oublie que la valeur est undefined avant ce cycle, on obtient des erreurs runtime difficiles à déboguer.
Voici le même composant avec l'API Signal Query d'Angular 17+ :
// ✅ Nouvelle approche Angular 19 — viewChild() signal-based
import { Component, viewChild, ElementRef, effect } from '@angular/core';
@Component({
selector: 'app-lecteur-audio',
standalone: true,
template: `
<audio #lecteur src="/assets/podcast-episode1.mp3"></audio>
<button (click)="lire()">Lire</button>
<button (click)="pause()">Pause</button>
`
})
export class LecteurAudioComponent {
// viewChild() retourne un Signal — pas besoin d'AfterViewInit
lecteur = viewChild<ElementRef<HTMLAudioElement>>('lecteur');
constructor() {
effect(() => {
// effect() réagit quand le signal devient disponible
const el = this.lecteur();
if (el) {
console.log('Lecteur prêt, durée :', el.nativeElement.duration);
}
});
}
lire() { this.lecteur()?.nativeElement.play(); }
pause() { this.lecteur()?.nativeElement.pause(); }
}
viewChild() retourne undefined. Après le rendu, il retourne la référence. C'est pourquoi on utilise ?. (optional chaining) lors de l'accès. La variante viewChild.required() garantit que la valeur est toujours définie après l'init — pas besoin de ?..
| Critère | @ViewChild (décorateur) | viewChild() (signal) |
|---|---|---|
| Disponibilité | Après AfterViewInit |
Signal — undefined puis défini |
| Cycle de vie requis | implements AfterViewInit |
Aucun |
| Réactivité | Manuelle (ngAfterViewChecked) | Automatique via effect() / computed() |
| Typage TypeScript | T | undefined — assert ! souvent utilisé |
Signal<T | undefined> — sûr par design |
| Testabilité | Complexe (simuler le cycle de vie) | Simple (signal = valeur observable) |
viewChild() : requêter son propre template
viewChild() permet d'accéder à un élément ou composant défini dans le template du composant courant. Il existe deux façons de cibler l'élément : par une template reference variable (#nom) ou par le type du composant.
Par template reference variable
// Accéder à un élément HTML natif avec une référence #nom
@Component({
selector: 'app-editeur-texte',
standalone: true,
template: `
<div class="editeur-container">
<textarea
#zoneTexte
class="form-control"
rows="8"
placeholder="Commencez à écrire..."
[(ngModel)]="contenu"
></textarea>
<div class="mt-2 text-muted">
{{ nombreCaracteres() }} / 500 caractères
</div>
<button class="btn btn-sm btn-outline-primary mt-2" (click)="focusZone()">
Mettre le focus
</button>
</div>
`
})
export class EditeurTexteComponent {
// 'zoneTexte' = nom de la template reference variable
// ElementRef<HTMLTextAreaElement> = type attendu
zoneTexte = viewChild<ElementRef<HTMLTextAreaElement>>('zoneTexte');
contenu = signal('');
nombreCaracteres = computed(() => this.contenu().length);
focusZone() {
// ?. car le signal peut être undefined si le template n'est pas rendu
this.zoneTexte()?.nativeElement.focus();
}
selectionnerTout() {
const textarea = this.zoneTexte()?.nativeElement;
if (textarea) {
textarea.select(); // Sélectionner tout le texte
}
}
}
Par type de composant enfant
// Accéder à une INSTANCE de composant enfant (pas juste le DOM)
import { Component, viewChild } from '@angular/core';
import { GraphiqueBarresComponent } from './graphique-barres.component';
@Component({
selector: 'app-tableau-de-bord',
standalone: true,
imports: [GraphiqueBarresComponent],
template: `
<h2>Ventes du mois</h2>
<app-graphique-barres [donnees]="donnéesVentes"></app-graphique-barres>
<button class="btn btn-outline-secondary mt-3" (click)="exporterGraphique()">
Exporter en PNG
</button>
`
})
export class TableauDeBordComponent {
donnéesVentes = signal([120, 95, 140, 88, 163, 177]);
// Cibler par TYPE — retourne l'instance du composant enfant
graphique = viewChild(GraphiqueBarresComponent);
exporterGraphique() {
// Appeler une méthode publique du composant enfant directement
this.graphique()?.exporterEnPng('ventes-mensuelles');
}
}
viewChild.required() — garantie de présence
// required() : Angular garantit que l'élément est toujours présent
@Component({
selector: 'app-carte-interactive',
standalone: true,
template: `
<!-- Cet élément est TOUJOURS dans le template — required() est safe -->
<div #conteneurCarte class="map-container"></div>
<p>Zoom actuel : {{ niveauZoom() }}x</p>
`
})
export class CarteInteractiveComponent {
// required() — pas de undefined, pas de ?. nécessaire
conteneurCarte = viewChild.required<ElementRef<HTMLDivElement>>('conteneurCarte');
niveauZoom = signal(12);
ngAfterViewInit() {
// Accès direct sans ?. — TypeScript sait que la valeur est toujours définie
const largeur = this.conteneurCarte().nativeElement.offsetWidth;
console.log(`Conteneur carte initialisé : ${largeur}px`);
}
}
viewChild.required() uniquement si l'élément est toujours présent dans le template — jamais dans un @if ou un @for. Si l'élément peut être absent (rendu conditionnel), utilisez viewChild() standard et gérez le cas undefined.
viewChildren() : requêter plusieurs éléments
Quand votre template contient plusieurs éléments du même type (liste d'items, grille de cartes, onglets), viewChildren() retourne un signal contenant un tableau de toutes les références correspondantes.
// Composant de galerie photo avec gestion de sélection multiple
import { Component, viewChildren, ElementRef, signal, computed } from '@angular/core';
interface Photo {
id: number;
url: string;
titre: string;
selectionnee: boolean;
}
@Component({
selector: 'app-galerie-photos',
standalone: true,
template: `
<div class="d-flex justify-content-between align-items-center mb-3">
<h3>Galerie — {{ photosSelectionnees() }} sélectionnée(s)</h3>
<button class="btn btn-primary btn-sm" (click)="telechargerSelection()"
[disabled]="photosSelectionnees() === 0">
Télécharger la sélection
</button>
</div>
<div class="row g-3">
@for (photo of photos(); track photo.id; let i = $index) {
<div class="col-md-3">
<div class="card cursor-pointer" [class.border-primary]="photo.selectionnee"
(click)="toggleSelection(i)">
<!-- #photoImg est placé sur chaque image — viewChildren les capture tous -->
<img #photoImg
[src]="photo.url"
[alt]="photo.titre"
class="card-img-top"
loading="lazy"
style="height: 180px; object-fit: cover">
<div class="card-body py-2">
<p class="card-text small">{{ photo.titre }}</p>
</div>
</div>
</div>
}
</div>
`
})
export class GaleriePhotosComponent {
// viewChildren retourne Signal<readonly ElementRef[]>
// Le signal se met à jour si des photos sont ajoutées ou supprimées
photoImgs = viewChildren<ElementRef<HTMLImageElement>>('photoImg');
photos = signal<Photo[]>([
{ id: 1, url: '/photos/paysage-alpes.jpg', titre: 'Alpes au lever du soleil', selectionnee: false },
{ id: 2, url: '/photos/portrait-artiste.jpg', titre: 'Portrait - Studio 12', selectionnee: false },
{ id: 3, url: '/photos/ville-nuit.jpg', titre: 'Paris la nuit', selectionnee: false },
{ id: 4, url: '/photos/macro-fleur.jpg', titre: 'Macro - Rose de jardin', selectionnee: false },
]);
photosSelectionnees = computed(() =>
this.photos().filter(p => p.selectionnee).length
);
toggleSelection(index: number) {
this.photos.update(liste =>
liste.map((p, i) => i === index ? { ...p, selectionnee: !p.selectionnee } : p)
);
}
telechargerSelection() {
// Accéder aux éléments DOM des images sélectionnées
const imgs = this.photoImgs(); // tableau de toutes les ElementRef
this.photos().forEach((photo, i) => {
if (photo.selectionnee && imgs[i]) {
// Logique de téléchargement via l'URL src de l'image
console.log('Téléchargement :', imgs[i].nativeElement.src);
}
});
}
}
viewChildren avec type de composant
// Accéder à plusieurs instances du même composant enfant
import { Component, viewChildren } from '@angular/core';
import { PanneauAccordeonComponent } from './panneau-accordeon.component';
@Component({
selector: 'app-faq',
standalone: true,
imports: [PanneauAccordeonComponent],
template: `
<h2>Questions fréquentes</h2>
@for (item of faqItems; track item.question) {
<app-panneau-accordeon
[titre]="item.question"
[contenu]="item.reponse">
</app-panneau-accordeon>
}
<button class="btn btn-sm btn-link mt-2" (click)="toutFermer()">
Tout replier
</button>
`
})
export class FaqComponent {
// Signal contenant toutes les instances de PanneauAccordeonComponent
panneaux = viewChildren(PanneauAccordeonComponent);
faqItems = [
{ question: 'Quels sont les délais de livraison ?', reponse: '...' },
{ question: 'Comment retourner un article ?', reponse: '...' },
{ question: 'Le paiement est-il sécurisé ?', reponse: '...' },
];
toutFermer() {
// Appeler la méthode fermer() sur chaque instance de panneau
this.panneaux().forEach(panneau => panneau.fermer());
}
}
contentChild() : requêter le contenu projeté
contentChild() est l'équivalent de viewChild() mais pour le contenu projeté via ng-content. C'est ce que le composant parent insère à l'intérieur de votre composant.
La distinction est fondamentale et souvent source de confusion pour les débutants :
| API | Interroge... | Accessible après... |
|---|---|---|
viewChild() |
Template du composant lui-même | AfterViewInit (ou signal devient défini) |
contentChild() |
Contenu injecté via ng-content |
AfterContentInit (ou signal devient défini) |
// Composant "conteneur carte" qui accepte du contenu projeté
// Le parent décide ce qui va dans l'en-tête et le corps
// carte-dashboard.component.ts
import { Component, contentChild, ElementRef } from '@angular/core';
@Component({
selector: 'app-carte-dashboard',
standalone: true,
template: `
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<!-- ng-content avec select : emplacement nommé -->
<ng-content select="[entete]"></ng-content>
<button class="btn btn-sm btn-outline-secondary" (click)="toggleContenu()">
{{ ouvert() ? '▲' : '▼' }}
</button>
</div>
@if (ouvert()) {
<div class="card-body">
<ng-content select="[corps]"></ng-content>
</div>
}
</div>
`
})
export class CarteDashboardComponent {
ouvert = signal(true);
// contentChild() — interroge ce que le PARENT a projeté avec [entete]
entete = contentChild<ElementRef>('[entete]');
toggleContenu() { this.ouvert.update(v => !v); }
obtenirTitreEntete(): string {
// Lire le texte de l'élément projeté en en-tête
return this.entete()?.nativeElement.textContent?.trim() ?? 'Sans titre';
}
}
// Utilisation du composant parent — il projette du contenu
@Component({
selector: 'app-page-accueil',
standalone: true,
imports: [CarteDashboardComponent],
template: `
<app-carte-dashboard>
<!-- Le parent projette ces éléments via ng-content -->
<h5 entete>Statistiques du jour</h5>
<div corps>
<div class="row text-center">
<div class="col"><span class="display-6 fw-bold">1 248</span><p>Visites</p></div>
<div class="col"><span class="display-6 fw-bold text-success">94</span><p>Ventes</p></div>
<div class="col"><span class="display-6 fw-bold text-warning">12</span><p>Retours</p></div>
</div>
</div>
</app-carte-dashboard>
`
})
export class PageAccueilComponent {}
<ng-content> dans votre template, vous aurez besoin de contentChild().
contentChildren() : plusieurs contenus projetés
Comme viewChildren() pour le template propre, contentChildren() collecte tous les éléments projetés correspondant à un type ou une référence.
// Composant de liste d'onglets — les onglets sont projetés par le parent
import { Component, contentChildren, signal, computed } from '@angular/core';
// Composant enfant simple représentant un onglet
@Component({
selector: 'app-onglet',
standalone: true,
template: `<ng-content></ng-content>`
})
export class OngletComponent {
@Input() libelle = '';
@Input() icone = '';
actif = signal(false);
}
// Composant conteneur — gère la navigation entre onglets
@Component({
selector: 'app-navigation-onglets',
standalone: true,
imports: [OngletComponent],
template: `
<!-- Barre des onglets -->
<ul class="nav nav-tabs mb-3" role="tablist">
@for (onglet of onglets(); track onglet.libelle; let i = $index) {
<li class="nav-item" role="presentation">
<button class="nav-link" [class.active]="ongletActifIndex() === i"
(click)="activerOnglet(i)" role="tab">
@if (onglet.icone) { <i class="bi bi-{{ onglet.icone }} me-1"></i> }
{{ onglet.libelle }}
</button>
</li>
}
</ul>
<!-- Contenu des onglets projetés -->
<div class="tab-content">
<ng-content></ng-content>
</div>
`
})
export class NavigationOngletsComponent {
// contentChildren collecte TOUS les OngletComponent projetés
onglets = contentChildren(OngletComponent);
ongletActifIndex = signal(0);
nombreOnglets = computed(() => this.onglets().length);
activerOnglet(index: number) {
this.ongletActifIndex.set(index);
// Mettre à jour l'état actif sur chaque onglet
this.onglets().forEach((onglet, i) => {
onglet.actif.set(i === index);
});
}
}
// Utilisation — le parent projette les onglets
@Component({
selector: 'app-profil-utilisateur',
standalone: true,
imports: [NavigationOngletsComponent, OngletComponent],
template: `
<app-navigation-onglets>
<app-onglet libelle="Informations" icone="person">
<h5>Profil personnel</h5>
<p>Nom : Alice Dupont</p>
</app-onglet>
<app-onglet libelle="Commandes" icone="bag">
<h5>Historique des commandes</h5>
<p>12 commandes passées</p>
</app-onglet>
<app-onglet libelle="Sécurité" icone="shield-lock">
<h5>Paramètres de sécurité</h5>
<p>Authentification à 2 facteurs : activée</p>
</app-onglet>
</app-navigation-onglets>
`
})
export class ProfilUtilisateurComponent {}
Réactivité avec effect() et computed()
L'un des grands avantages des Signal Queries est leur intégration naturelle avec effect() et computed(). Vous pouvez dériver des valeurs et réagir aux changements sans ngAfterViewInit ni ngAfterViewChecked.
Réagir au montage d'un élément avec effect()
// Initialiser une librairie JS externe quand l'élément est disponible
import { Component, viewChild, ElementRef, effect, signal } from '@angular/core';
@Component({
selector: 'app-editeur-code',
standalone: true,
template: `
<div class="mb-2">
<select class="form-select form-select-sm w-auto d-inline"
(change)="changerLangue($event)">
<option value="javascript">JavaScript</option>
<option value="typescript">TypeScript</option>
<option value="html">HTML</option>
</select>
</div>
<!-- L'éditeur CodeMirror sera monté sur cet élément -->
<div #zoneEditeur class="border rounded" style="min-height: 200px"></div>
`
})
export class EditeurCodeComponent {
zoneEditeur = viewChild<ElementRef<HTMLDivElement>>('zoneEditeur');
langueActive = signal('javascript');
private editeurInstance: any = null;
constructor() {
effect(() => {
const el = this.zoneEditeur();
if (!el) return; // Pas encore rendu — on attend
if (!this.editeurInstance) {
// Initialiser CodeMirror une seule fois quand l'élément est prêt
// this.editeurInstance = CodeMirror(el.nativeElement, { ... });
console.log('Éditeur initialisé sur :', el.nativeElement);
}
});
effect(() => {
// Réagir au changement de langue — l'éditeur doit déjà exister
const langue = this.langueActive();
if (this.editeurInstance) {
// this.editeurInstance.setOption('mode', langue);
console.log('Langue changée :', langue);
}
});
}
changerLangue(event: Event) {
this.langueActive.set((event.target as HTMLSelectElement).value);
}
}
Dériver des données avec computed()
// Calculer des statistiques sur les composants enfants
@Component({
selector: 'app-liste-taches',
standalone: true,
template: `
<div class="d-flex gap-3 mb-4 p-3 bg-light rounded">
<div>Total : <strong>{{ stats().total }}</strong></div>
<div class="text-success">Terminées : <strong>{{ stats().terminees }}</strong></div>
<div class="text-warning">En cours : <strong>{{ stats().enCours }}</strong></div>
<div>Progression : <strong>{{ stats().pourcentage }}%</strong></div>
</div>
@for (tache of taches; track tache.id) {
<app-tache-item [tache]="tache"></app-tache-item>
}
`
})
export class ListeTachesComponent {
tacheItems = viewChildren(TacheItemComponent);
taches = [
{ id: 1, titre: 'Concevoir la maquette', statut: 'termine' },
{ id: 2, titre: 'Développer le back-end', statut: 'en-cours' },
{ id: 3, titre: 'Écrire les tests', statut: 'en-cours' },
{ id: 4, titre: 'Déployer en production', statut: 'a-faire' },
];
// computed() recalcule automatiquement quand tacheItems change
stats = computed(() => {
const items = this.tacheItems();
const total = items.length;
const terminees = items.filter(t => t.statut() === 'termine').length;
const enCours = items.filter(t => t.statut() === 'en-cours').length;
const pourcentage = total > 0 ? Math.round((terminees / total) * 100) : 0;
return { total, terminees, enCours, pourcentage };
});
}
Le paramètre read : accéder à différentes interfaces
Par défaut, viewChild() retourne l'instance du composant ou une ElementRef pour les éléments HTML natifs. Mais un même élément du template peut exposer plusieurs interfaces : le composant, sa ElementRef, une directive appliquée dessus, ou un TemplateRef. Le paramètre read vous laisse choisir.
import { Component, viewChild, ElementRef, ViewContainerRef, TemplateRef } from '@angular/core';
import { NgModel } from '@angular/forms';
@Component({
selector: 'app-formulaire-avance',
standalone: true,
imports: [FormsModule],
template: `
<input
#champEmail
type="email"
class="form-control"
[(ngModel)]="email"
required
email
>
<!-- Template de référence pour injection dynamique -->
<ng-template #gabaritErreur>
<div class="alert alert-danger mt-2">Email invalide</div>
</ng-template>
<div #emplacementErreur></div>
`
})
export class FormulaireAvanceComponent {
email = signal('');
// Accéder à l'ElementRef de l'input (élément DOM)
champEmailEl = viewChild<ElementRef<HTMLInputElement>>('champEmail');
// Accéder à la directive NgModel appliquée sur ce MÊME input
champEmailModel = viewChild('champEmail', { read: NgModel });
// Accéder au TemplateRef d'un ng-template
gabaritErreur = viewChild('gabaritErreur', { read: TemplateRef });
// Accéder au ViewContainerRef d'un élément (pour injection dynamique)
emplacementErreur = viewChild('emplacementErreur', { read: ViewContainerRef });
afficherErreur() {
const container = this.emplacementErreur();
const template = this.gabaritErreur();
if (container && template) {
container.clear(); // Vider les erreurs précédentes
container.createEmbeddedView(template); // Injecter le template d'erreur
}
}
verifierValidite() {
const ngModel = this.champEmailModel();
if (ngModel?.invalid) {
this.afficherErreur();
}
}
}
read. Il devient utile quand vous avez besoin d'accéder à une directive spécifique appliquée sur un élément, ou quand vous manipulez dynamiquement des templates avec ViewContainerRef.
Migration depuis les décorateurs et bonnes pratiques
Guide de migration rapide
| Ancienne syntaxe (décorateur) | Nouvelle syntaxe (signal) |
|---|---|
@ViewChild('ref') el!: ElementRef |
el = viewChild<ElementRef>('ref') |
@ViewChild(MonComp) comp!: MonComp |
comp = viewChild(MonComp) |
@ViewChildren(MonComp) items!: QueryList<MonComp> |
items = viewChildren(MonComp) |
@ContentChild('ref') el!: ElementRef |
el = contentChild<ElementRef>('ref') |
@ContentChildren(MonComp) items!: QueryList<MonComp> |
items = contentChildren(MonComp) |
Supprimer les cycles de vie inutiles
// Avant la migration — AfterViewInit et AfterContentInit requis
@Component({ /* ... */ })
export class AvantMigration implements AfterViewInit, AfterContentInit {
@ViewChild('canvas') canvas!: ElementRef;
@ContentChild(LegendeComponent) legende!: LegendeComponent;
ngAfterViewInit() {
// Initialiser le canvas seulement ici
this.initialiserCanvas(this.canvas.nativeElement);
}
ngAfterContentInit() {
// Accéder à la légende projetée seulement ici
console.log(this.legende.texte);
}
}
// Après la migration — aucun cycle de vie nécessaire
@Component({ /* ... */ })
export class ApresMigration {
canvas = viewChild<ElementRef>('canvas');
legende = contentChild(LegendeComponent);
constructor() {
// effect() détecte automatiquement quand les signaux deviennent définis
effect(() => {
const el = this.canvas();
if (el) this.initialiserCanvas(el.nativeElement);
});
effect(() => {
const leg = this.legende();
if (leg) console.log(leg.texte());
});
}
}
Checklist de migration
- Remplacer
@ViewChildparviewChild()importé depuis@angular/core - Remplacer
@ViewChildrenparviewChildren()—QueryListn'est plus nécessaire - Remplacer
@ContentChildparcontentChild() - Remplacer
@ContentChildrenparcontentChildren() - Remplacer
ngAfterViewInitpar uneffect()si la logique ne dépend que du signal - Ajouter
?.sur tous les accès aux signaux non-required - Utiliser
.required()uniquement si l'élément est toujours dans le template (jamais dans un@if) - Supprimer les imports
AfterViewInit,AfterContentInitsi plus utilisés
viewChild, viewChildren, contentChild, contentChildren) remplacent les décorateurs Angular classiques par des signaux réactifs. Ils suppriment le besoin de AfterViewInit et AfterContentInit dans la majorité des cas, rendent le code plus lisible et s'intègrent naturellement avec effect() et computed(). Commencez la migration sur vos nouveaux composants standalone, et progressez sur les existants.