📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~3 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. 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't existe pas — TypeError garanti
    new Chart(this.chartRef.nativeElement, { type: 'bar', data: {} });
  }
}

Deux problèmes distincts ici :

  1. La réactivité : dans un effect(), this.chartRef n'est pas traqué par le système de signals. L'effet ne se redéclenche pas si la ref change (ex : @if qui affiche/masque le canvas).
  2. Le timing SSR : ngAfterViewInit s'exécute côté serveur en mode SSR. Si tu touches le DOM sans vérifier l'environnement, c'est TypeError: HTMLCanvasElement is not a constructor en 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 est disponible après l'initialisation de la vue — comme avant. Mais là où @ViewChild t'obligeait à utiliser ngAfterViewInit (exécuté côté serveur en SSR), les signals te poussent naturellement vers afterRender() qui est, lui, ignoré côté serveur.

import { viewChild, ElementRef, afterRender } 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() {
    // afterRender : uniquement côté client, après chaque render
    afterRender(() => {
      if (!this.chartInstance) {
        this.chartInstance = new Chart(this.chartRef().nativeElement, {
          type: 'bar',
          data: { labels: ['Jan', 'Fév', 'Mar'], datasets: [{ data: [12, 19, 3] }] }
        });
      }
    });
  }
}

afterRender() ne s'exécute jamais côté serveur. Tu élimine d'un coup la vérification isPlatformBrowser() que tout le monde oublie d'ajouter.

Pour du DOM read/write optimisé (eviter le layout thrashing), Angular propose aussi des phases :

import { afterRender, AfterRenderPhase } from '@angular/core';

afterRender(() => {
  // Phase READ : lecture des dimensions (évite reflow forcé)
  const width = this.chartRef().nativeElement.getBoundingClientRect().width;
}, { phase: AfterRenderPhase.Read });

afterRender(() => {
  // Phase WRITE : modifications DOM après les lectures
  this.chartRef().nativeElement.style.height = `${width * 0.6}px`;
}, { phase: AfterRenderPhase.Write });

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 + afterRender, SSR-safe
import { Component, signal, viewChild, viewChildren,
         ElementRef, afterRender } 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() {
    afterRender(() => {
      // 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() afterRender(() => { ... })
Réagir aux changements de liste QueryList.changes.subscribe() effect(() => { this.refs(); ... })

Checklist de migration :

  • Remplace @ViewChild par viewChild() ou viewChild.required()
  • Remplace @ViewChildren par viewChildren()
  • Remplace @ContentChild / @ContentChildren par leurs équivalents signal
  • Déplace le code DOM de ngAfterViewInit vers afterRender()
  • Supprime les isPlatformBrowser() devenus inutiles
  • Supprime les QueryList.changes.subscribe() et leur unsubscribe()

Les signal queries sont disponibles depuis Angular 17 et stables depuis Angular 18. Si tu es sur une version récente, il n'y a aucune raison de garder les décorateurs pour du nouveau code.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

EasyAngularKit

Formation complète pour maîtriser Angular et développer des applications web modernes.

Navigation

Contact

Légal

© 2026 Easy Angular Kit. Tous droits réservés.