~5 min de lecture
Tu utilises encore @ViewChild ? Voici le piège SSR qu'il te cache
Dans une app Angular moderne orientée signals, @ViewChild crée une friction invisible. Pas d'erreur à la compilation, pas de crash au runtime - juste un pattern qui résiste à la réactivité et qui plante discrètement en SSR dès que tu touches le DOM trop tôt.
Les signal queries - viewChild(), viewChildren(), contentChild(), contentChildren() - sont disponibles depuis Angular 17.2. Si tu n'as pas encore basculé, ce guide te montre pourquoi tu devrais, et comment le faire sans se prendre les pieds dans le tapis.
Le problème : @ViewChild n'est pas réactif
Avec les décorateurs, la ref est une simple propriété de classe. Elle est populée par Angular après l'init de la vue, mais elle n'est pas un signal. Résultat : impossible de l'utiliser directement dans un computed() ou un effect() sans ruse.
// ❌ Ce pattern compile, mais déclenche des surprises
@Component({ template: `<canvas #chart></canvas>` })
export class Dashboard {
@ViewChild('chart') chartRef!: ElementRef<HTMLCanvasElement>;
constructor() {
// chartRef est undefined ici - Angular ne l'a pas encore populé
effect(() => {
// Si tu lis chartRef.nativeElement ici, c'est undefined au premier passage
console.log(this.chartRef?.nativeElement);
});
}
ngAfterViewInit() {
// ✅ Ça fonctionne... mais en SSR, cette méthode s'exécute côté serveur
// et HTMLCanvasElement n'existe pas - TypeError garanti
new Chart(this.chartRef.nativeElement, { type: 'bar', data: {} });
}
}
Deux problèmes distincts ici :
- La réactivité : dans un
effect(),this.chartRefn'est pas traqué par le système de signals. L'effet ne se redéclenche pas si la ref change (ex :@ifqui affiche/masque le canvas). - Le timing SSR :
ngAfterViewInits'exécute côté serveur en mode SSR. Si tu touches le DOM sans vérifier l'environnement, c'estTypeError: HTMLCanvasElement is not a constructoren production.
viewChild() : la version réactive
La signal query retourne un Signal<T | undefined>. Elle se met à jour automatiquement quand l'élément apparaît ou disparaît du DOM (derrière un @if, par exemple).
import { viewChild, ElementRef, Component, signal, effect } from '@angular/core';
@Component({
template: `
@if (showChart()) {
<canvas #chart></canvas>
}
<button (click)="toggle()">Toggle</button>
`
})
export class Dashboard {
showChart = signal(true);
chartRef = viewChild<ElementRef<HTMLCanvasElement>>('chart');
constructor() {
effect(() => {
const canvas = this.chartRef(); // Signal - traqué automatiquement
if (canvas) {
console.log('Canvas disponible :', canvas.nativeElement);
// L'effet se redéclenche quand showChart change
}
});
}
toggle() {
this.showChart.update(v => !v);
}
}
L'effect() se redéclenche automatiquement chaque fois que chartRef() change de valeur - que l'élément apparaisse ou disparaisse du template. Plus besoin de ngOnChanges ou de ngAfterViewChecked pour surveiller ça.
viewChild.required() : fini le !
Si l'élément est toujours présent dans le template (pas derrière un @if), utilise required(). Le type retourné est Signal<T> sans undefined - plus de ! ou de ?. défensifs dans ton code.
// ✅ Signal<ElementRef<HTMLCanvasElement>> - jamais undefined
chartRef = viewChild.required<ElementRef<HTMLCanvasElement>>('chart');
// ✅ Accès direct sans vérification
effect(() => {
const canvas = this.chartRef().nativeElement;
// canvas est garanti défini
});
Si Angular ne trouve pas l'élément au runtime avec required(), il lève une erreur explicite - bien mieux que de silencieusement planter plus tard.
viewChildren() : toute une liste de refs
Pour récupérer plusieurs éléments du même template variable ou de la même classe :
import { viewChildren, ElementRef } from '@angular/core';
@Component({
template: `
@for (item of items(); track item.id) {
<div #card class="card">{{ item.title }}</div>
}
`
})
export class CardList {
items = signal([{ id: 1, title: 'A' }, { id: 2, title: 'B' }]);
// Signal<readonly ElementRef<HTMLDivElement>[]>
cards = viewChildren<ElementRef<HTMLDivElement>>('card');
constructor() {
effect(() => {
console.log(`${this.cards().length} cartes dans le DOM`);
// Se redéclenche automatiquement quand items() change
});
}
}
La liste se met à jour en temps réel avec le @for. Pas de QueryList avec son changes observable à gérer manuellement - c'est un signal comme les autres.
contentChild() et contentChildren() : pour ng-content
Même logique pour les éléments projetés via <ng-content>. Utile quand tu construis un composant de layout ou une librairie.
// card.ts
import { contentChild, contentChildren, ElementRef } from '@angular/core';
@Component({
selector: 'app-card',
template: `
<div class="card">
<ng-content select="[header]" />
<ng-content />
</div>
`
})
export class Card {
// Récupère l'élément avec l'attribut [header] projeté par le parent
header = contentChild<ElementRef>('header');
// Ou par directive/composant
// actions = contentChildren(CardAction);
constructor() {
effect(() => {
const h = this.header();
if (h) {
console.log('Header projeté :', h.nativeElement.textContent);
}
});
}
}
<!-- parent.html -->
<app-card>
<h2 #header header>Mon titre</h2>
<p>Contenu de la carte</p>
</app-card>
contentChild.required() et contentChildren() suivent exactement la même API que leurs équivalents viewChild.
Le piège SSR : quand le signal est-il populé ?
Avec les signal queries, la ref se résout à la demande, à la première lecture du signal, et non à un instant fixe du cycle de vie. La nuance change ce que tu peux écrire et où :
- Cible statique (un
#elinconditionnel dans le template) : le signal est déjà populé dansngOnInit. Pas besoin d'attendrengAfterViewInit. - Cible dans une vue embarquée (une vue que le template crée à la volée) :
ngOnInitte rendundefined. Pour@if,@for,@switchetngTemplateOutlet, cette vue est créée pendant la passe de rendu du composant hôte, doncngAfterViewInitsuffit. - Cible dans un
@defer: là, aucun hook de cycle de vie ne suffit,ngAfterViewInitcompris. Le passage à l'état rendu passe systématiquement par une microtâche - qui se résout après la passe synchrone oùngAfterViewInits'exécute, donc le hook est déjà passé quand le contenu arrive - même lorsque le bloc n'a aucune dépendance à charger. C'est ce qui rend la règle vraie quel que soit le trigger. - Lecture dans le constructeur : trop tôt dans tous les cas. Une query
.required()lue là lève uneNG0951, dont le message estChild query result is required but no value is available.
Le cas @defer est le plus vicieux de la liste : seul afterEveryRender() - ou un effect() sur la query, puisque c'est un signal - voit la ref arriver, et en attendant ton ngAfterViewInit lit undefined sans lever la moindre erreur.
Autrement dit, ce n'est pas de savoir si ton @if est vrai ou faux qui décide - un @if (true) te rend quand même undefined en ngOnInit. Ce qui décide, c'est le moment où la vue est créée : la résolution reste paresseuse, mais elle ne peut trouver que ce qui existe déjà quand tu lis.
Et côté serveur ?
Côté SSR, la vraie différence est ailleurs. Là où @ViewChild t'obligeait à utiliser ngAfterViewInit (exécuté côté serveur en SSR), les signals te poussent naturellement vers afterEveryRender() qui est, lui, ignoré côté serveur.
À partir d'Angular 20, ce hook s'appelle
afterEveryRender()- il s'appelaitafterRender()jusqu'à la v19 incluse, retiré net à sa stabilisation en v20 (pas de période de dépréciation). Même chose pour l'optionphase/ l'enumAfterRenderPhaseutilisées plus bas, remplacées par la forme à objet (earlyRead,write,mixedReadWrite,read). Détails dans le guide dédié.
import { viewChild, ElementRef, afterEveryRender } from '@angular/core';
import { Chart } from 'chart.js';
@Component({
template: `<canvas #chart></canvas>`
})
export class Dashboard {
chartRef = viewChild.required<ElementRef<HTMLCanvasElement>>('chart');
private chartInstance: Chart | null = null;
constructor() {
// afterEveryRender : uniquement côté client, après chaque render
afterEveryRender(() => {
if (!this.chartInstance) {
this.chartInstance = new Chart(this.chartRef().nativeElement, {
type: 'bar',
data: { labels: ['Jan', 'Fév', 'Mar'], datasets: [{ data: [12, 19, 3] }] }
});
}
});
}
}
afterEveryRender() ne s'exécute jamais côté serveur. Tu élimines d'un coup la vérification isPlatformBrowser() que tout le monde oublie d'ajouter.
Pour du DOM read/write optimisé (éviter le layout thrashing), Angular propose aussi des phases :
import { afterEveryRender } from '@angular/core';
afterEveryRender({
earlyRead: () => this.chartRef().nativeElement.getBoundingClientRect().width,
write: (width) => {
this.chartRef().nativeElement.style.height = `${width() * 0.6}px`;
},
});
Avant / après : migration complète
Voici une migration réelle d'un composant de slider avec @ViewChild, un QueryList et ngAfterViewInit :
// ❌ AVANT - décorateurs, QueryList, lifecycle manuel
import { Component, ViewChild, ViewChildren, QueryList,
ElementRef, AfterViewInit, OnDestroy } from '@angular/core';
@Component({
template: `
<div #track class="track">
<div *ngFor="let slide of slides" #slide class="slide">
{{ slide.label }}
</div>
</div>
`
})
export class Slider implements AfterViewInit, OnDestroy {
slides = [{ label: 'A' }, { label: 'B' }, { label: 'C' }];
@ViewChild('track') trackRef!: ElementRef<HTMLDivElement>;
@ViewChildren('slide') slideRefs!: QueryList<ElementRef<HTMLDivElement>>;
private subscription = Subscription.EMPTY;
ngAfterViewInit() {
// Exécuté en SSR - plante si HTMLDivElement absent
this.initSlider(this.trackRef.nativeElement);
// QueryList.changes : Observable à souscrire manuellement
this.subscription = this.slideRefs.changes.subscribe(() => {
this.updateSlider(this.slideRefs.toArray());
});
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
private initSlider(el: HTMLDivElement) { /* ... */ }
private updateSlider(slides: ElementRef[]) { /* ... */ }
}
// ✅ APRÈS - signal queries + afterEveryRender, SSR-safe
import { Component, signal, viewChild, viewChildren,
ElementRef, afterEveryRender } from '@angular/core';
@Component({
template: `
<div #track class="track">
@for (slide of slides(); track slide.label) {
<div #slide class="slide">{{ slide.label }}</div>
}
</div>
`
})
export class Slider {
slides = signal([{ label: 'A' }, { label: 'B' }, { label: 'C' }]);
trackRef = viewChild.required<ElementRef<HTMLDivElement>>('track');
slideRefs = viewChildren<ElementRef<HTMLDivElement>>('slide');
constructor() {
afterEveryRender(() => {
// Côté client uniquement - pas besoin de isPlatformBrowser()
this.initSlider(this.trackRef().nativeElement);
});
// slideRefs() est un signal - effect() se redéclenche si slides() change
effect(() => {
const slides = this.slideRefs();
if (slides.length > 0) {
this.updateSlider(slides);
}
});
}
private initSlider(el: HTMLDivElement) { /* ... */ }
private updateSlider(slides: readonly ElementRef[]) { /* ... */ }
}
Ce qu'on a éliminé : implements AfterViewInit, implements OnDestroy, Subscription, .subscribe(), .unsubscribe(), QueryList, .toArray(), et le risque SSR.
Récap actionnable
| Situation | Avant | Après |
|---|---|---|
| Ref unique, toujours présente | @ViewChild('ref') ref!: T |
ref = viewChild.required<T>('ref') |
Ref optionnelle (@if) |
@ViewChild('ref') ref?: T |
ref = viewChild<T>('ref') |
| Liste d'éléments | @ViewChildren('ref') refs!: QueryList<T> |
refs = viewChildren<T>('ref') |
| Contenu projeté | @ContentChild('ref') ref!: T |
ref = contentChild.required<T>('ref') |
| Init DOM côté client uniquement | ngAfterViewInit + isPlatformBrowser() |
afterEveryRender(() => { ... }) |
| Réagir aux changements de liste | QueryList.changes.subscribe() |
effect(() => { this.refs(); ... }) |
Checklist de migration :
- Remplace
@ViewChildparviewChild()ouviewChild.required() - Remplace
@ViewChildrenparviewChildren() - Remplace
@ContentChild/@ContentChildrenpar leurs équivalents signal - Déplace le code DOM de
ngAfterViewInitversafterEveryRender() - Supprime les
isPlatformBrowser()devenus inutiles - Supprime les
QueryList.changes.subscribe()et leurunsubscribe()
Les signal queries sont disponibles depuis Angular 17.2 (en developer preview) et stables depuis Angular 19. Si tu es sur une version récente, il n'y a aucune raison de garder les décorateurs pour du nouveau code.