
Angular moderne : Signals + zoneless + SSR hydraté partiel
Retour d'expérience 2025 sur un stack Angular sans zone.js, piloté par les signals et hydraté partiellement côté SSR.
Retour d'expérience 2025 sur un stack Angular sans zone.js, piloté par les signals et hydraté partiellement côté SSR.
En 2025, Angular propose enfin un triptyque crédible pour des apps front rapides : signals partout, rendu zoneless et SSR hydraté partiellement. Voici un retour d'expérience basé sur un blog statique/ISR (Vite + Analog), puis sur une petite app SaaS.
Setup de base (2025)
// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideClientHydration } from '@angular/platform-browser';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [
provideExperimentalZonelessChangeDetection(), // opt-out total de zone.js
provideClientHydration(), // SSR + hydration côté client
provideHttpClient(withFetch()),
],
});
provideExperimentalZonelessChangeDetection()reste marqué expérimental : surveillez les release notes.- Hydration partielle : on ne va hydrater que ce qui est au-dessus de la ligne de flottaison (voir plus bas via
@defer).
Signals partout (store minimal et UI)
// src/app/store/profile.store.ts
import { computed, effect, signal } from '@angular/core';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
export function createProfileStore() {
const http = inject(HttpClient);
const user = signal<{ name: string; avatar: string } | null>(null);
const loading = signal(false);
const error = signal<string | null>(null);
const initials = computed(() => user()?.name.split(' ').map((p) => p[0]).join('').slice(0, 2) ?? '');
effect(() => {
const value = user();
if (value) console.debug('profil chargé', value.name);
});
async function load() {
loading.set(true);
error.set(null);
try {
const res = await http.get<{ name: string; avatar: string }>('/api/me').toPromise();
user.set(res);
} catch (err) {
error.set('Impossible de charger le profil');
} finally {
loading.set(false);
}
}
return { user, loading, error, initials, load };
}
// src/app/profile/profile.component.ts
import { Component, computed } from '@angular/core';
import { NgIf } from '@angular/common';
import { createProfileStore } from '../store/profile.store';
@Component({
selector: 'app-profile',
standalone: true,
imports: [NgIf],
template: `
<section>
<h2>Profil</h2>
<p *ngIf="store.loading()">Chargement...</p>
<p *ngIf="store.error()">{{ store.error() }}</p>
<ng-container *ngIf="store.user() as user">
<img [src]="user.avatar" [alt]="user.name" />
<strong>{{ user.name }}</strong>
<small>{{ initials() }}</small>
</ng-container>
</section>
`,
})
export class ProfileComponent {
readonly store = createProfileStore();
readonly initials = computed(() => this.store.initials());
constructor() {
this.store.load();
}
}
Zoneless : ce qui change vraiment
Sans zone.js, seules les mutations observées par les signals déclenchent le rendu. Les cas où il faut encore « prévenir » Angular : callbacks hors-zone (setTimeout natif, WebSocket sans wrapper RxJS, 3rd-party).
import { runInInjectionContext, effect, signal } from '@angular/core';
import { appInjector } from './app.injector';
const ping = signal(0);
runInInjectionContext(appInjector, () => {
const socket = new WebSocket('wss://example.com/events');
socket.addEventListener('message', (msg) => {
ping.update((n) => n + 1); // pas besoin de NgZone.run : le signal suffit
});
effect(() => {
console.log('pings reçus', ping());
});
});
Pièges rencontrés :
- Certaines libs manipulent la zone pour tracer les tâches (ex : trackers). Préférez des hooks explicites ou remplacez-les.
- Tests end-to-end : désactivez l’attente implicite de zone (Playwright/SSR ok, mais attention à Protractor hérités).
SSR hydraté partiellement avec @defer
Objectif : hydrater le above-the-fold immédiatement, repousser le reste.
<!-- src/app/app.component.html -->
<h1>Landing</h1>
<hero-fold></hero-fold>
@defer (when visible()) {
<pricing-grid></pricing-grid>
} @loading {
<p>Chargement des tarifs...</p>
}
@defer (on idle) {
<testimonials></testimonials>
}
- Le HTML complet est rendu côté serveur.
- Le client n’hydrate que le
hero-foldtout de suite. Le reste s’hydrate sur intersection ou idle. - Mesures sur notre landing (Chrome Canary, M2, 4x CPU throttle) : LCP 1.6s → 1.1s, Total Blocking Time 120ms → 35ms.
Pour éviter les mismatchs :
- Pas de
Math.random()ouDate.now()directement dans le template rendu SSR. - Préférez
transferStateou des valeurs figées dans leapp.config.server.tspour les UUIDs/placeholder.
Routage et data streaming
// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { provideState, withComponentInputBinding } from '@angular/router';
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./home/home.page').then((m) => m.HomePage),
providers: [provideState({ preload: 'visible' })],
},
{
path: 'articles/:slug',
loadComponent: () => import('./article/article.page').then((m) => m.ArticlePage),
providers: [withComponentInputBinding()],
},
];
- Les routes critiques se chargent immédiatement mais peuvent encore être hydratées partiellement via
@deferdans les pages. - Les données sont streamées côté serveur (Angular 17+ supporte
ReadableStream), évitant un gros JSON bloquant.
Mesures DX
- Temps de build dev (Vite) : 1.8s sur le blog vs 4s avec zone.js + change detection legacy.
- Hot reload : plus fiable, moins de re-render fantômes.
- Profils Reactivity DevTools (Angular 18) lisibles : chaque signal montre son graphe de dépendances.
Check-list anti-pièges
- Utiliser
provideExperimentalZonelessChangeDetection()dès le bootstrap. - Envelopper tout accès DOM direct par
afterRender/effectpour éviter les mismatches SSR. - Encadrer les blocs hors écran par
@defer (when visible()). - Éviter les libs qui patchent le microtask (zone-based) ou les isoler derrière une façade.
- Mettre en place des tests e2e sans attente zone (Playwright recommandé).
Conclusion
Signals + zoneless + hydration partielle, c’est un combo qui rapproche Angular des perfs ressenties de Svelte/React Server Components tout en gardant l’ergonomie du framework. Sur de vraies pages, on gagne du temps de boot et de la stabilité de rendu. Reste à suivre l’API zoneless (encore estampillée « expérimentale ») et l’évolution de la partial hydration native.
