L'hydration est l'étape critique qui connecte le HTML pré-rendu côté serveur (ou généré en static site generation) à l'application Angular interactive côté client. Sans elle, le navigateur charge une page figée ; avec une mauvaise implémentation, les utilisateurs voient un flash de contenu non-stylisé, un DOM qui scintille ou des éléments qui disparaissent une seconde avant de réapparaître. Ce phénomène, appelé FOUC (Flash of Unstyled Content) ou plus rarement FOUT, détruit l'expérience utilisateur et nuit au Core Web Vitals, notamment au CLS (Cumulative Layout Shift).
Angular 14+ a introduit l'hydration native avec provideClientHydration() , une amélioration majeure par rapport aux anciennes approches ad-hoc. Comprendre son mécanisme permet non seulement d'éviter les pièges visuels, mais aussi de servir du contenu plus rapidement aux utilisateurs et aux crawlers. Cet article détaille les causes du flash, les stratégies pour l'éliminer, et les pièges réels que rencontrent les équipes en production.
Comment fonctionne l'hydration Angular
Quand vous servez une application Angular avec Universal (ou un pré-rendu statique), le serveur génère le HTML initial et l'envoie au navigateur. Le navigateur affiche ce HTML brut, puis le JavaScript Angular se charge en arrière-plan. Une fois le bundle téléchargé et exécuté, Angular doit « hydrater » le DOM existant : c'est-à-dire attacher les event listeners, initialiser l'état réactif (signals, RxJS), et synchroniser le state côté client avec le rendu côté serveur. Si cette synchronisation échoue ou est mal orchestrée, le navigateur recalcule le layout et repeint le DOM, créant le flash visible.
Angular détecte les nœuds du DOM pré-rendus en comparant la structure HTML statique avec celle générée par la logique composant côté client. Si elles divergent, l'hydration échoue et Angular recrée entièrement le DOM (fallback à la création, pas à l'hydration). Ce processus est coûteux en CPU et en rendu, et c'est exactement ce flash que vous voyez. Les zones d'hydration (introduites en Angular 15) permettent de délimiter quelles parties du DOM doivent être hydratées, offrant une granularité plus fine.
Le piège classique : état initial non-synchronisé
Le piège le plus courant : le serveur rend un composant avec un état initial, mais le client initialise cet état différemment. Par exemple, le serveur affiche une liste de produits récupérée depuis une API, mais le client initie une nouvelle requête qui arrive un instant plus tard. Pendant ce délai, le DOM affiché n'existe plus (le serveur l'a remplacé), ce qui force Angular à recréer la structure.
Voici un exemple problématique. Côté serveur, vous injectez les données directement dans le template :
// component.server.ts ou composant universel
@Component({
selector: 'app-products',
template: <div>{{ products | json }}</div>
})
export class ProductsComponent {
products = []; // serveur : données injectées via un resolver
constructor(private route: ActivatedRoute) {
this.products = this.route.snapshot.data['products'];
}
}
Côté client, le même composant recharge les données :
// client
export class ProductsComponent implements OnInit {
products = signal([]);
constructor(private api: ApiService) {}
ngOnInit() {
this.api.getProducts().subscribe(data => {
this.products.set(data); // ré-fetch = flash !
});
}
}
La solution : utiliser un transfert de state. Angular fournit TransferState pour passer les données du serveur au client sans re-requête.
Éviter le flash avec TransferState et provideClientHydration
TransferState est un service qui stocke les données côté serveur et les réinjecte côté client, court-circuitant les requêtes HTTP. Combiné avec provideClientHydration() , il élimine le flash causé par les re-fetches.
Côté serveur, vous stockez les données dans le TransferState :
// product.service.ts
export class ProductService {
constructor(private http: HttpClient, private transferState: TransferState) {}
getProducts() {
const key = makeStateKey('products');
const products = this.transferState.get(key, null);
if (products) return of(products);
return this.http.get('/api/products').pipe(
tap(data => this.transferState.set(key, data))
);
}
}
Côté client, provideClientHydration() restaure automatiquement le state et les requêtes HTTP ne sont pas rejouées si les données existent déjà. Résultat : le DOM reste stable, pas de flash.
// main.ts
bootstrapApplication(AppComponent, {
providers: [provideClientHydration()]
});
Gérer le CSS et les animations pendant l'hydration
Un autre source de flash : le CSS n'est pas appliqué immédiatement au HTML serveur. Si votre bundle CSS est chargé via une balise <link> asynchrone ou dépend d'une feuille de styles générée au runtime (par exemple, avec Tailwind JIT), le navigateur affiche d'abord le HTML non-stylisé quelques millisecondes avant que le CSS arrive. C'est le FOUC classique.
Pour minimiser ce risque, inlinisez le CSS critique dans le <head> du HTML serveur. Universal permet de le faire automatiquement si vous compilez avec --configuration production et que votre setup Tailwind génère un fichier CSS statique (non-JIT en prod). Alternativement, utilisez des media queries ou des classes d'initialisation pour masquer le contenu jusqu'à ce que le CSS soit chargé.
Une approche éprouvée : appliquer une classe .hydrating au <body> côté serveur, puis la retirer côté client une fois l'hydration complète. Les styles CSS peuvent utiliser cette classe pour masquer ou dégager le contenu progressivement.
Zones d'hydration : contrôler la granularité
Pour les applications complexes avec beaucoup de contenu statique ou avec des sections non-interactives (sidebars, footers), les zones d'hydration offrent un contrôle précis. Vous polez déclarer quelles parties du DOM doivent être hydratées et lesquelles restent inactives (static skip hydration).
// component.ts
import { NgSkipHydration } from '@angular/core';
@Component({
selector: 'app-main',
template: `
<div ngSkipHydration>Contenu statique, jamais hydraté</div>
<app-interactive></app-interactive>
})
export class MainComponent {}
Cette directive indique à Angular d'ignorer ce nœud et ses enfants lors de l'hydration, réduisant le temps de traitement et évitant les mismatches. Utile pour les contenu générés côté serveur mais jamais modifiés côté client.
Tester l'hydration en local et en staging
Un piège courant : tester uniquement en mode SSR avec ng serve --ssr , qui contient souvent des optimisations de dev et des logs qui masquent les vrais problèmes. Pour vraiment valider, construisez la version production ( ng build && ng build --configuration ssr ) et servez-la localement avec Node.js pur. Déactivez aussi le cache du navigateur (DevTools > Network > Disable cache) pour simuler un premier chargement réel.
Utilisez Lighthouse et Web Vitals pour mesurer le CLS et le LCP après l'hydration. Inspectez le DevTools pour voir si le DOM change entre le SSR initial et la prise de contrôle par Angular (le DevTools affichera des mutations DOM si l'hydration échoue). Un outil comme Hydration Debugger (extension Chrome Angular) peut aussi aider à identifier les mismatches.
Piège courant : dépendances non-déterministes
L'hydration échoue si le serveur et le client produisent des résultats différents pour la même logique. Exemples typiques : utiliser Math.random() , Date.now() , ou des valeurs basées sur le timezone ou la locale. Si le serveur affiche un ID aléatoire dans un attribut data et que le client en génère un différent, l'hydration échouera.
La solution : passer les valeurs non-déterministes via le state transfer ou les utiliser après l'hydration (dans ngAfterViewInit ou avec des guards). Évitez aussi d'utiliser des données de requête côté serveur (comme les en-têtes HTTP ou l'IP client) sans les passer explicitement au client.
Conclusion et checklist opérationnelle
L'hydration fluide demande une synchronisation minutieuse entre le serveur et le client. Les trois points clés : (1) utiliser TransferState pour éviter les re-fetches, (2) inliner le CSS critique et gérer le chargement asynchrone des styles, (3) tester en production build avec cache désactivé.
Votre checklist avant de déployer : ✓ provideClientHydration() configuré. ✓ Requêtes HTTP serveur stockées avec TransferState . ✓ CSS critique inliné, feuilles asynchrones chargées sans flash. ✓ Zones d'hydration utilisées pour le contenu statique. ✓ Pas de logique non-déterministe dans les templates rendus. ✓ Lighthouse et Web Vitals validés en production build. ✓ Inspecteur DevTools confirme aucune mutation DOM post-hydration. Appliquez ces principes et votre application offrira une expérience utilisateur rapide et fluide dès le premier chargement.