Standalone components & inject()
TL;DR — Depuis Angular 14 (preview) et stable en 15, le concept de
NgModulen'est plus obligatoire. Un composant standalone se déclare directement comme l'unité de composition : il importe ses dépendances dansimports: [...], est bootstrappé viabootstrapApplication, et expose ses providers de façon hiérarchique. La fonctioninject()complète ce changement en permettant de récupérer une dépendance n'importe où dans un contexte d'injection (constructeur, factory, guard fonctionnel, resolver fonctionnel, interceptor fonctionnel) sans avoir à passer par un constructeur. Depuis Angular 17 c'est le défaut des schematics, et en 19-20 quasiment toute la doc officielle suppose du standalone.
🧠 Mental model — ASCII + analogie
Pense au composant standalone comme à un module Python ou un module ES : il déclare lui-même ce qu'il importe en haut du fichier, et ce qu'il exporte. Plus besoin d'un NgModule intermédiaire pour jouer le rôle de "registre" entre composants, directives, pipes et providers.
┌─────────────────────────────────────────────┐
│ ANGULAR < 14 (NgModule) │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ AppModule │ │
│ │ declarations: [A, B, C] │ │
│ │ imports: [CommonModule, FormsModule]│ │
│ │ providers: [SvcX] │ │
│ │ exports: [A] │ │
│ └─────────────────────────────────────┘ │
│ ▲ │
│ │ "il faut tout déclarer ici" │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ ANGULAR >= 17 (standalone) │
│ │
│ @Component({ │
│ standalone: true, ← défaut en 19+ │
│ imports: [NgIf, FormsModule, ChildCmp], │
│ providers: [SvcX], │
│ }) │
│ export class MyCmp { │
│ private svc = inject(SvcX); │
│ } │
│ │
│ bootstrapApplication(AppCmp, { │
│ providers: [provideRouter(routes)] │
│ }) │
└─────────────────────────────────────────────┘L'analogie la plus fidèle est celle des fichiers .vue ou .svelte : chaque composant est auto-contenu, déclare ses dépendances, et le "module" comme niveau d'abstraction disparaît au profit du graphe d'imports.
L'autre changement clé est inject() : avant, le seul moyen de demander une dépendance à Angular était d'écrire constructor(private svc: Svc). C'est très contraignant — le code DI ne peut vivre que dans des classes, et avec des subtilités d'héritage (super calls). inject() permet d'appeler la résolution DI partout où Angular a configuré un contexte d'injection.
constructor(private x: X) {} vs private x = inject(X);
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ - lié aux classes uniquement │ │ - utilisable hors classes │
│ - super() obligatoire │ │ (guards fonctionnels, │
│ - relou avec génériques │ │ factories, resolvers) │
│ - typage explicite │ │ - hérite naturellement │
└──────────────────────────────┘ │ - readonly facile │
│ - inférence parfaite │
└──────────────────────────────┘Un contexte d'injection est un endroit où Angular sait à quel injecteur rattacher l'appel : initialisation d'un champ de composant/directive/service, exécution d'une factory, à l'intérieur de runInInjectionContext, ou pendant l'évaluation d'un guard/resolver/interceptor fonctionnel. Hors de ce contexte, inject() lance NG0203.
🛠️ Code minimal
// app.component.ts
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterOutlet } from '@angular/router';
import { UserService } from './user.service';
@Component({
selector: 'app-root',
standalone: true, // implicite depuis Angular 19
imports: [CommonModule, RouterOutlet],
template: `
<header>Bienvenue {{ user.name() }}</header>
<router-outlet />
`,
})
export class AppComponent {
// inject() au lieu du constructeur
protected readonly user = inject(UserService);
private readonly router = inject(Router);
goHome(): void {
this.router.navigate(['/']);
}
}// user.service.ts
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class UserService {
readonly name = signal('Anonyme');
}// main.ts — bootstrap sans AppModule
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withFetch()),
],
}).catch((err) => console.error(err));// app.routes.ts — routes 100% standalone + lazy loading natif
import { Routes } from '@angular/router';
export const routes: Routes = [
{ path: '', loadComponent: () => import('./home/home.component').then((m) => m.HomeComponent) },
{ path: 'admin', loadChildren: () => import('./admin/admin.routes').then((m) => m.adminRoutes) },
];Tout ce code fonctionne sans aucun NgModule. Le lazy loading se fait par composant (loadComponent) ou par sous-arbre de routes (loadChildren qui pointe vers un Routes[]).
🎯 Patterns courants
0. Anatomie minimale d'une app 100% standalone
Voici la structure de fichiers typique d'une app Angular 20 fraîchement créée :
src/
├── main.ts ← bootstrapApplication
├── index.html
├── styles.scss
└── app/
├── app.component.ts ← composant racine standalone
├── app.config.ts ← providers (provideRouter, provideHttpClient...)
├── app.routes.ts ← Routes (loadComponent / loadChildren)
└── features/
├── todos/
│ ├── todos.component.ts
│ └── todos.service.ts
└── settings/
├── settings.component.ts
└── settings.routes.tsLe fichier app.config.ts centralise la configuration :
// app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { provideZonelessChangeDetection } from '@angular/core';
import { routes } from './app.routes';
import { authInterceptor } from './interceptors/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
],
};// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
bootstrapApplication(AppComponent, appConfig).catch(console.error);Cette séparation entre main.ts (boot) et app.config.ts (providers) est la convention recommandée — plus testable, plus lisible, et permet d'avoir plusieurs configs (browser, SSR, e2e).
1. Guard fonctionnel avec inject()
// auth.guard.ts
import { CanActivateFn, Router } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn()) return true;
return router.parseUrl(`/login?from=${encodeURIComponent(state.url)}`);
};C'est typiquement le cas d'usage qui justifie à lui seul inject() : avant, il fallait écrire une classe @Injectable qui implémente CanActivate. La version fonctionnelle est plus concise, tree-shakable, et purement testable comme une fonction.
2. Resolver fonctionnel typé
// user.resolver.ts
import { ResolveFn } from '@angular/router';
import { inject } from '@angular/core';
import { UserService } from './user.service';
import { User } from './user.model';
export const userResolver: ResolveFn<User> = (route) => {
const svc = inject(UserService);
const id = route.paramMap.get('id')!;
return svc.getById(id);
};Le type ResolveFn<User> garantit que le composant cible reçoit bien un User typé via withComponentInputBinding().
3. Interceptor fonctionnel (HttpInterceptorFn)
// auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const token = auth.token();
if (!token) return next(req);
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
};
// logging.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { tap } from 'rxjs';
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
const started = performance.now();
return next(req).pipe(
tap({
finalize: () => console.debug(`${req.method} ${req.url} — ${(performance.now() - started).toFixed(0)}ms`),
}),
);
};
// app.config.ts
provideHttpClient(withInterceptors([authInterceptor, loggingInterceptor]));L'ordre dans le tableau définit l'ordre d'exécution : authInterceptor voit la requête en premier et la réponse en dernier (modèle d'oignon, comme un middleware Express ou un @nestjs/common interceptor). Donc place le plus « infrastructurel » (logging, tracing) à l'extérieur, et le plus « métier » (auth, retry) à l'intérieur.
4. Factory provider avec inject()
import { InjectionToken, inject } from '@angular/core';
import { ConfigService } from './config.service';
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL', {
providedIn: 'root',
factory: () => {
const cfg = inject(ConfigService);
return cfg.env === 'prod' ? 'https://api.example.com' : 'http://localhost:3000';
},
});
// Consommation
const url = inject(API_BASE_URL);Note : la factory du token tourne déjà dans un contexte d'injection — pas besoin de runInInjectionContext.
5. Sortir du contexte avec runInInjectionContext
import { Component, EnvironmentInjector, inject, runInInjectionContext } from '@angular/core';
@Component({ /* ... */ })
export class DashboardComponent {
private readonly envInjector = inject(EnvironmentInjector);
async loadPlugin(): Promise<void> {
const { computeStats } = await import('./stats-plugin');
// computeStats() utilise inject() en interne — il faut donc un contexte
const stats = runInInjectionContext(this.envInjector, () => computeStats());
console.log(stats);
}
}Indispensable quand on appelle du code qui utilise inject() après une opération asynchrone (typiquement après un await, où on a perdu le contexte d'origine).
6. Service standalone (providedIn: 'root')
@Injectable({ providedIn: 'root' })
export class TodoService {
private readonly http = inject(HttpClient);
private readonly base = inject(API_BASE_URL);
list() {
return this.http.get<Todo[]>(`${this.base}/todos`);
}
}Tree-shakable : si aucun composant ne fait inject(TodoService), le service n'est pas inclus dans le bundle.
7. Héritage de classes simplifié avec inject()
// AVANT — super() obligatoire, lourd avec des génériques
abstract class BaseListComponent<T> {
constructor(protected http: HttpClient, protected route: ActivatedRoute) {}
}
@Component({ /* ... */ })
class UserListComponent extends BaseListComponent<User> {
constructor(http: HttpClient, route: ActivatedRoute, private svc: UserService) {
super(http, route); // ← obligatoire, et il faut répéter les deps
}
}
// APRÈS — pas de super-call à propager
abstract class BaseListComponent<T> {
protected readonly http = inject(HttpClient);
protected readonly route = inject(ActivatedRoute);
}
@Component({ /* ... */ })
class UserListComponent extends BaseListComponent<User> {
private readonly svc = inject(UserService);
// Pas de constructeur du tout
}C'est sans doute le gain le plus immédiat d'inject() au quotidien : finie la cascade de super(...) à maintenir quand on ajoute une dépendance dans la classe parente.
8. Capturer un DestroyRef pour la fin de vie
import { Component, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({ /* ... */ })
export class HeartbeatComponent {
private readonly destroyRef = inject(DestroyRef);
ngOnInit(): void {
interval(1000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => console.log('tick'));
this.destroyRef.onDestroy(() => console.log('component destroyed'));
}
}takeUntilDestroyed() sans argument capture automatiquement le DestroyRef du composant à condition d'être appelé dans un contexte d'injection (typiquement, l'initialisation d'un champ ou le constructeur). Sinon il faut passer le DestroyRef explicitement, comme ci-dessus.
🔄 Versions — Angular 16 / 17 / 18 / 19 / 20
| Version | Apport principal sur standalone & inject() |
|---|---|
| 14 (jun 2022) | standalone: true en preview, bootstrapApplication introduit, schematics expérimentaux. |
| 15 (nov 2022) | Standalone stable. Directives & pipes peuvent aussi être standalone. |
| 16 (mai 2023) | inject() arrive comme alternative claire au constructeur, support de Required Inputs (@Input({ required: true })), DestroyRef + takeUntilDestroyed(). |
| 17 (nov 2023) | Schematics standalone par défaut (ng new, ng generate component créent du standalone). loadComponent + nouveau control flow rendent les apps 100% standalone naturelles. |
| 18 (mai 2024) | @defer stable, zoneless en developer preview, provideExperimentalZonelessChangeDetection. |
| 19 (nov 2024) | standalone: true devient implicite : on peut l'omettre. Pour rester en NgModule il faut standalone: false. Schematics de migration plus aboutis. |
| 20 (mai 2025) | Zoneless stable (provideZonelessChangeDetection). La doc officielle considère NgModule comme legacy. |
Le timeline pratique à retenir : si tu démarres un projet en 2026, tu fais du 100% standalone, point. Les seules raisons d'utiliser encore NgModule sont l'intégration de libs anciennes ou un monolithe legacy non encore migré.
⚠️ Pitfalls
inject()hors contexte d'injection — ErreurNG0203: inject() must be called from an injection context. Cause typique : appel après unawait, à l'intérieur d'unsetTimeout, dans un callback RxJS. Solution : capturer les dépendances au plus tôt (champs de classe) ou utiliserrunInInjectionContext.- Import oublié — Avec standalone, si tu utilises
*ngIfouNgClass, il faut importer la directive elle-même (ouCommonModule). Sans NgModule pour faire le pont, l'oubli se traduit par un parser error ou un binding qui ne fait rien. Avec le nouveau control flow (@if,@for), ce piège disparaît. - Providers dupliqués — Un provider défini à la fois dans
providersau niveau application et au niveau composant crée deux instances. Le composant utilise sa propre instance, ce qui peut briser un singleton (ex. cache, store). providedIn: 'any'peu connu — Donne une instance par module lazy / EnvironmentInjector. Utile pour un service qui doit être isolé entre features chargées dynamiquement, mais piège classique : on s'attend à un singleton root.- Constructor injection +
inject()mélangés — Légal mais incohérent. Choisir une convention par projet (préférence Angular 20 :inject()partout pour la cohérence et la simplicité d'héritage). inject()dans une classe abstraite parente — Marche bien, contrairement à un constructeur où il faut passer les dépendances viasuper(...). C'est un des gros gains d'inject().- DestroyRef + inject() — Un service injecté avec
inject(DestroyRef)dans un composant capture le cycle de vie du composant, pas du service. Bien lire le contexte d'appel. - Schematic de migration sur monorepo —
ng generate @angular/core:standalonemigre tout, mais peut casser les libs internes si elles dépendent encore d'NgModuleexporté. Migrer par feature. - Tests qui dépendent de NgModule —
TestBed.configureTestingModule({ declarations: [...] })ne marche pas pour un composant standalone : il faut le mettre dansimports. HostBinding/HostListenervshost: {}— Indépendant de standalone, mais on tombe souvent dessus en migrant. Privilégierhost: {}en 17+ (et@HostBindingreste valide).bootstrapApplicationvsplatformBrowserDynamic— Le second reste utilisé pour l'app shell avec NgModule legacy. Le premier ne marche qu'avec un composant standalone. Migrer le bootstrap est l'ultime étape, pas la première — sinon on se retrouve avec un AppModule orphelin.@Injectable()sansprovidedIn— Toujours valide, mais alors il faut le mettre dans unproviders: [...]quelque part. Oublier les deux donneNullInjectorError.inject(ChangeDetectorRef)dans un service — Ne marche pas (un service n'a pas de CD). Si tu en as besoin, c'est un signe que la logique devrait vivre dans un composant ou être déclenchée différemment.
🧪 Testing — TestBed avec standalone
import { TestBed } from '@angular/core/testing';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { TodoListComponent } from './todo-list.component';
import { TodoService } from './todo.service';
describe('TodoListComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TodoListComponent], // <-- imports, pas declarations
providers: [provideHttpClientTesting(), TodoService],
});
});
it('rend la liste vide initialement', () => {
const fixture = TestBed.createComponent(TodoListComponent);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Aucune tâche');
});
});Tester un guard fonctionnel
import { TestBed } from '@angular/core/testing';
import { RouterStateSnapshot, ActivatedRouteSnapshot } from '@angular/router';
import { authGuard } from './auth.guard';
import { AuthService } from './auth.service';
describe('authGuard', () => {
it('autorise quand connecté', () => {
TestBed.configureTestingModule({
providers: [{ provide: AuthService, useValue: { isLoggedIn: () => true } }],
});
// runInInjectionContext est implicite via TestBed.runInInjectionContext
const result = TestBed.runInInjectionContext(() =>
authGuard({} as ActivatedRouteSnapshot, { url: '/x' } as RouterStateSnapshot)
);
expect(result).toBe(true);
});
});C'est la grosse différence vs CanActivate classe : on teste comme une fonction pure, sans avoir à instancier une classe DI complète.
Surcharger un inject() dans un test
TestBed.overrideProvider(API_BASE_URL, { useValue: 'http://mock' });Tester un interceptor fonctionnel
import { TestBed } from '@angular/core/testing';
import { HttpRequest, HttpHandlerFn } from '@angular/common/http';
import { of } from 'rxjs';
import { authInterceptor } from './auth.interceptor';
import { AuthService } from './auth.service';
it('ajoute le header Authorization quand un token est présent', (done) => {
TestBed.configureTestingModule({
providers: [{ provide: AuthService, useValue: { token: () => 'abc' } }],
});
const req = new HttpRequest('GET', '/api/me');
const next: HttpHandlerFn = (r) => {
expect(r.headers.get('Authorization')).toBe('Bearer abc');
return of({} as any);
};
TestBed.runInInjectionContext(() => {
authInterceptor(req, next).subscribe(() => done());
});
});Override sur un composant standalone : mocker un enfant
TestBed.configureTestingModule({ imports: [ParentComponent] })
.overrideComponent(ParentComponent, {
remove: { imports: [ExpensiveChildComponent] },
add: { imports: [FakeChildComponent] },
});API officielle (Angular 17+) pour remplacer un import à la volée dans un test — l'équivalent de l'ancien MockComponent mais natif.
🎬 Cas d'usage concrets
Scénario 1 — SaaS RH (ATS) migré du NgModule vers standalone
Un éditeur d'ATS (suivi des candidatures) tourne sur Angular 12 depuis 2021. L'équipe a fini par accumuler 4 SharedModule imbriqués, un CoreModule de 600 lignes et un fichier app.module.ts qui importe 38 features. Le bootstrap initial dépasse 2,4 MB de JS, et chaque ajout de feature provoque une régression cyclique de dépendances dans le build.
Le tech lead pousse une migration progressive vers les composants standalone. La règle adoptée : toute nouvelle feature est standalone, et les feature existantes basculent une à une lorsqu'on les touche pour autre chose (règle du Boy Scout). En 4 sprints, 70 % du code est passé en standalone. Le loadComponent remplace les anciens loadChildren, ce qui supprime quatre couches d'NgModule intermédiaires utilisés uniquement pour héberger une route et un composant. Le bundle initial tombe à 1,1 MB. Le DX gagne aussi : les développeurs juniors comprennent enfin où sont importés CommonModule et RouterLink, parce qu'ils sont déclarés explicitement dans le imports du composant qui les utilise.
Le piège évité : ne pas migrer le SharedModule en bloc. L'équipe a créé un fichier shared-imports.ts qui exporte un tableau (SHARED_IMPORTS = [CommonModule, RouterLink, ButtonComponent, ...]) et chaque composant standalone fait imports: [...SHARED_IMPORTS]. Avantage : pas de big bang, et tree-shaking immédiat si le composant n'utilise qu'une partie.
Scénario 2 — Cabinet juridique, nouveau dashboard partenaires
Un cabinet d'avocats d'affaires lance un portail partenaires (avocats associés + assistants juridiques) à partir de zéro, en Angular 17. Greenfield, donc tout est standalone par défaut. Le tech lead profite de la simplicité d'inject() pour modéliser un système d'autorisation très précis, où chaque écran (dossier, facture, contrat) requiert un rôle. Plutôt que de polluer chaque composant avec un constructeur à 6 dépendances, les services sont injectés via inject() au niveau des champs.
@Component({ standalone: true, /* ... */ })
export class CaseDetailComponent {
private readonly auth = inject(AuthService);
private readonly cases = inject(CaseService);
private readonly route = inject(ActivatedRoute);
}Les guards et resolvers passent en fonctionnels : canActivateFn: HasRoleGuard('associate') retourne une fonction qui appelle inject(AuthService) directement. Le résultat : pas un seul NgModule dans tout le projet, build initial à 380 KB gzip, et un graphe de dépendances trivial à auditer (utile pour la revue de sécurité commandée par le DPO du cabinet).
Scénario 3 — E-commerce, back-office admin
Une marketplace de mode B2C exploite un back-office Angular 16 utilisé par une centaine d'administrateurs internes (catalogue, promotions, support). Le projet est encore en NgModule mais l'équipe veut introduire les composants standalone pour les nouvelles pages d'analytics, plus lourdes en libs (charts, exports XLSX). L'objectif est de lazy-loader ces pages sans alourdir le bundle principal.
La stratégie : la route /admin/analytics utilise loadComponent: () => import('./analytics/analytics.page').then(m => m.AnalyticsPage) au lieu du classique loadChildren. La page est standalone et importe explicitement ChartsModule (lib tierce non standalone, donc encore en NgModule — la cohabitation est gérée nativement). Le bundle d'analytics est isolé dans son propre chunk de 220 KB chargé à la demande.
L'équipe découvre un bénéfice annexe : les tests d'intégration sont plus simples. TestBed.configureTestingModule({ imports: [AnalyticsPage] }) suffit, et overrideComponent permet de mocker ChartComponent (lent à rendre en JSDOM) sans toucher au composant testé.
🛠️ Exemple end-to-end
Use case : portail RH d'un éditeur SaaS — page « Détail candidat » construite en standalone, avec injection fonctionnelle, guard de rôle et resolver, le tout sans le moindre NgModule.
// auth.service.ts
import { Injectable, signal, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
export type Role = 'recruiter' | 'manager' | 'admin';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
readonly currentUser = signal<{ id: string; role: Role } | null>(null);
hasRole(role: Role): boolean {
return this.currentUser()?.role === role;
}
}// has-role.guard.ts
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { AuthService, Role } from './auth.service';
export const hasRole =
(role: Role): CanActivateFn =>
() => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.hasRole(role) ? true : router.createUrlTree(['/forbidden']);
};// candidate.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { CandidateService, Candidate } from './candidate.service';
export const candidateResolver: ResolveFn<Candidate> = (route) => {
const id = route.paramMap.get('id')!;
return inject(CandidateService).getById(id);
};// candidate-detail.component.ts
import { Component, inject } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { AsyncPipe, DatePipe } from '@angular/common';
import { map } from 'rxjs';
@Component({
selector: 'app-candidate-detail',
standalone: true,
imports: [RouterLink, AsyncPipe, DatePipe],
template: `
@if (candidate$ | async; as c) {
<h1>{{ c.firstName }} {{ c.lastName }}</h1>
<p>Postulé le {{ c.appliedAt | date: 'longDate' }}</p>
<a routerLink="/candidates">Retour à la liste</a>
}
`,
})
export class CandidateDetailComponent {
private readonly route = inject(ActivatedRoute);
protected readonly candidate$ = this.route.data.pipe(map((d) => d['candidate']));
}// app.routes.ts
import { Routes } from '@angular/router';
import { hasRole } from './auth/has-role.guard';
import { candidateResolver } from './candidates/candidate.resolver';
export const routes: Routes = [
{
path: 'candidates/:id',
canActivate: [hasRole('recruiter')],
resolve: { candidate: candidateResolver },
loadComponent: () =>
import('./candidates/candidate-detail.component').then((m) => m.CandidateDetailComponent),
},
];// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
import { authInterceptor } from './app/auth/auth.interceptor';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
],
});Aucun NgModule n'est utilisé. Le composant est lazy-loadé, protégé par un guard fonctionnel, et reçoit son Candidate déjà résolu via le router. Tous les services et l'interceptor HTTP sont activés à la racine via bootstrapApplication.
🔁 Quand utiliser / éviter
| Utiliser quand | Éviter quand |
|---|---|
| Nouveau projet (Angular 17+) | Bibliothèque qui doit supporter Angular ≤ 14 |
Lazy loading de composant simple (loadComponent) | Codebase legacy avec circular deps non résolus côté NgModule |
| Migration progressive (cohabitation OK) | Tests existants tous écrits autour de declarations (effort de migration vs valeur) |
| Guards/resolvers/interceptors fonctionnels | Intégration avec une lib tierce qui n'expose que des NgModule (entoure-la, ne la migre pas) |
Services partagés cross-feature (providedIn: 'root') | Quand un collègue n'a pas encore vu le pattern — investir 30 min de pairing d'abord |
🔬 Détails du contexte d'injection
Le contexte d'injection est l'invariant qui permet à inject() de savoir dans quel injecteur résoudre. Concrètement, Angular l'établit dans :
- Le constructeur d'une classe injectable (composant, directive, pipe, service décoré).
- L'initialisation d'un champ de cette même classe (
x = inject(...)). - Une
factoryd'InjectionTokenou d'un provideruseFactory. - Un
effect()créé dans un contexte d'injection. - Un guard, resolver ou interceptor fonctionnel (Angular les exécute via
runInInjectionContext). - Manuellement via
runInInjectionContext(injector, fn).
Hors de ces cas, inject() lance une erreur. La règle pratique : si tu écris inject() dans un endroit "exotique" (callback de timeout, après await, dans un constructeur de class non-Angular), il y a 90% de chances qu'il faille soit capturer la valeur plus tôt, soit utiliser runInInjectionContext.
// Exemple complet : utiliser inject() après un await
@Component({ /* ... */ })
export class PluginHostComponent {
private readonly env = inject(EnvironmentInjector);
async loadAndRun(): Promise<void> {
const mod = await import('./feature-plugin');
// mod.run() utilise inject() pour récupérer ses dépendances
runInInjectionContext(this.env, () => mod.run());
}
}🏛️ Le modèle de résolution DI — comment un staff y pense
inject(X) n'est pas magique : c'est une remontée d'arbre. Angular maintient deux hiérarchies d'injecteurs qui se croisent, et savoir laquelle répond à une requête donnée est ce qui sépare le débogage de cinq minutes du débogage d'une heure.
EnvironmentInjector (environnement / "module") ElementInjector (DOM / composants)
───────────────────────────────────────── ──────────────────────────────────
NullInjector (lance NullInjectorError)
▲
PlatformInjector (providePlatform...)
▲
RootEnvironmentInjector (bootstrapApplication providers, providedIn:'root')
▲
RouteEnvironmentInjector (providers d'une route lazy)
▲ AppComponent (providers: [...])
└───────── fusion à l'élément hôte du composant ───────────▲
ChildComponent (providers: [...])
▲
inject(X) part d'ICI ───┘La règle de résolution : inject(X) remonte d'abord la chaîne des ElementInjector (du composant courant vers ses ancêtres dans le DOM), puis bascule sur la chaîne des EnvironmentInjector (route lazy → root → platform), et termine au NullInjector qui lève NG0201: No provider for X. Le premier provider rencontré gagne — d'où les « providers dupliqués » du pitfall #3 : un provider local masque le singleton root pour ce sous-arbre.
Les modificateurs (@Self, @SkipSelf, @Optional, @Host)
Avec inject() ils deviennent des options de la signature :
import { inject, Optional } from '@angular/core';
// équivalent de @Optional() : undefined au lieu de throw
const logger = inject(LOGGER, { optional: true }); // LOGGER | null
// @SkipSelf : ignore le providers du composant courant, cherche chez l'ancêtre
const parentStore = inject(STORE, { skipSelf: true });
// @Self : n'autorise QUE l'injecteur courant (sinon throw)
const localOnly = inject(CONFIG, { self: true });
// @Host : s'arrête à l'élément hôte du composant (utile pour les directives)
const hostForm = inject(NgForm, { host: true, optional: true });Mental model par défaut : { optional, self, skipSelf, host }. Combinaison la plus utile en pratique : { optional: true, skipSelf: true } pour un pattern « hérite de la config du parent ou prends un défaut » (équivalent du providedIn: 'root' factory avec fallback).
Tableau de décision — où provisionner un service
| Portée souhaitée | Où déclarer | Instance(s) | Tree-shakable | Cas typique |
|---|---|---|---|---|
| Singleton global | @Injectable({ providedIn: 'root' }) | 1 | ✅ | Services métier, HTTP, store global |
| Une par sous-arbre lazy | providedIn: 'platform' / providers de route | 1 par EnvironmentInjector lazy | partiel | State isolé d'une feature lazy |
| Une par instance de composant | providers: [X] sur le @Component | N (1/instance) | ❌ | Form state, drag controller, sélection locale |
| Une par instance de directive | providers sur la directive | N | ❌ | Tooltip controller, validateur |
| Jamais en singleton root | providedIn: 'any' | 1 par injecteur lazy + 1 root | partiel | Rare ; piège classique (pitfall #4) |
Comment un staff tranche : par défaut providedIn: 'root' (tree-shakable, testable, un seul cycle de vie à raisonner). On descend au niveau composant uniquement quand l'état doit mourir avec la vue (form, wizard, sélection) — sinon on crée des fuites mémoire et des bugs de « state qui persiste entre deux ouvertures ». providedIn: 'platform' est réservé aux apps multi-bootstrap (micro-frontends Angular Elements partageant un service entre plusieurs roots).
Performance & bundle
inject()et le constructeur DI compilent vers le même code — aucun coût runtime, aucun gain de perf à migrer (le gain est l'ergonomie, pas la vitesse).- Le vrai levier bundle est
providedIn: 'root'+loadComponent/loadChildren: un service jamais injecté est tree-shaké, et le code d'une route lazy n'entre dans aucun chunk avant navigation. Mesure avecsource-map-explorer dist/**/*.jsaprèsng build --stats-json. - Piège de perf observé en prod : un service
providedIn: 'root'qui fait du travail lourd dans son champinject()(ex. parser un gros JSON) s'exécute au premierinject, pas au bootstrap — diffère bien, mais ne le mets pas dans un champ d'AppComponentsinon il bloque le premier rendu.
🤖 UI d'agent IA — streaming de tokens en standalone + zoneless
Le topic « standalone + inject() » est exactement la fondation d'une UI de chat IA moderne : un service providedIn: 'root' qui parle au backend, injecté via inject() dans un composant standalone zoneless, avec rendu append-only des tokens. Voici le pattern de production (Angular 20, zoneless, signals).
// chat.service.ts — DI'd, providedIn:'root', streaming via fetch + ReadableStream
import { Injectable, inject, signal } from '@angular/core';
import { DOCUMENT } from '@angular/common';
export type ChatRole = 'user' | 'assistant';
export interface ChatMessage {
readonly id: string;
readonly role: ChatRole;
text: string; // muté en place pendant le stream (append-only)
done: boolean;
}
@Injectable({ providedIn: 'root' })
export class ChatService {
private readonly doc = inject(DOCUMENT);
readonly messages = signal<ChatMessage[]>([]);
readonly streaming = signal(false);
// un seul controller à la fois → Stop côté client ET serveur
private controller: AbortController | null = null;
async send(prompt: string): Promise<void> {
const userMsg: ChatMessage = { id: crypto.randomUUID(), role: 'user', text: prompt, done: true };
const aiMsg: ChatMessage = { id: crypto.randomUUID(), role: 'assistant', text: '', done: false };
// push immuable des deux messages (re-render), puis on mutera aiMsg.text en place
this.messages.update((m) => [...m, userMsg, aiMsg]);
this.streaming.set(true);
this.controller = new AbortController();
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, generationId: aiMsg.id }), // idempotency key
signal: this.controller.signal,
});
if (!res.body) throw new Error('no stream');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffered = '';
let raf = 0;
// rAF-coalescing : on ne notifie le signal qu'une fois par frame, pas par token
const flush = () => {
raf = 0;
this.messages.update((m) => [...m]); // nouvelle ref → CD zoneless re-render
};
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffered += decoder.decode(value, { stream: true });
// protocole SSE-like : lignes "data: <token>\n\n"
let nl: number;
while ((nl = buffered.indexOf('\n\n')) !== -1) {
const line = buffered.slice(0, nl).replace(/^data: /, '');
buffered = buffered.slice(nl + 2);
if (line === '[DONE]') continue;
aiMsg.text += line; // mutation en place — pas de copie par token
if (!raf) raf = requestAnimationFrame(flush); // coalesce
}
}
} catch (e) {
if ((e as Error).name !== 'AbortError') aiMsg.text += '\n⚠️ erreur de génération';
} finally {
aiMsg.done = true;
this.streaming.set(false);
this.messages.update((m) => [...m]); // flush final
this.controller = null;
}
}
stop(): void {
this.controller?.abort(); // annule fetch → le navigateur ferme la connexion → le serveur voit req.on('close')
}
}// chat.component.ts — standalone, zoneless, signals, Stop button
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ChatService } from './chat.service';
@Component({
selector: 'app-chat',
standalone: true,
imports: [FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush, // zoneless aime OnPush
template: `
<ul>
@for (m of chat.messages(); track m.id) {
<li [class.assistant]="m.role === 'assistant'">
<strong>{{ m.role }}</strong>
<pre>{{ m.text }}</pre>
@if (!m.done) { <span class="cursor">▋</span> }
</li>
}
</ul>
<form (submit)="onSubmit($event)">
<input name="q" [(ngModel)]="draft" [disabled]="chat.streaming()" />
@if (chat.streaming()) {
<button type="button" (click)="chat.stop()">Stop</button>
} @else {
<button type="submit" [disabled]="!draft.trim()">Envoyer</button>
}
</form>
`,
})
export class ChatComponent {
protected readonly chat = inject(ChatService);
protected draft = '';
onSubmit(e: Event): void {
e.preventDefault();
const p = this.draft.trim();
if (!p) return;
this.draft = '';
void this.chat.send(p);
}
}Pourquoi ce design est le bon (raisonnement staff) :
inject(ChatService)dans un champ = contexte d'injection valide, le service est un singleton root testable et mockable (TestBed.overrideProvider).- Append-only + mutation en place de
aiMsg.text: on ne recrée pas le tableau à chaque token (O(n²) garanti sur les longues réponses). On mute la string, et on déclenche un re-render coalescé parrequestAnimationFrame→ au plus 60 re-renders/s même si le LLM crache 200 tokens/s. C'est la différence entre une UI fluide et un thread bloqué. - Zoneless : sans Zone.js, le
await reader.read()ne déclenche aucune CD automatique. C'est un avantage ici — on contrôle exactement quand re-render (lemessages.update([...m])change la référence et notifie le signal). En mode zone, chaque microtask aurait re-déclenché la CD globale. AbortController: un seul Stop annule lefetch. Le navigateur ferme la socket, et un backend NestJS bien fait écoutereq.on('close')/ passe leAbortSignalà l'appel SDK Anthropic pour arrêter de payer des tokens côté serveur. Le bouton Stop sans annulation serveur est un anti-pattern coûteux.generationIdenvoyé au serveur = clé d'idempotence : si le client retry (reconnexion réseau), le backend peut renvoyer la sortie partielle déjà bufferisée au lieu de relancer (et re-facturer) une génération.
Côté serveur, le pendant NestJS de ce composant (SSE, boucle agentique tool-use,
forRootAsyncpour un clientAnthropicDIّ, BullMQ pour les jobs longs, cost-guard à l'edge) est traité dans les fiches NestJS. Modèles Anthropic de référence :claude-opus-4-8(flagship),claude-sonnet-4-6,claude-haiku-4-5— toujours via le SDK en mode streaming avec ses retries intégrés.
Markdown + sécurité : ne jamais [innerHTML] la sortie LLM brute. Rends le markdown via une lib (marked) puis passe par DomSanitizer :
import { Component, SecurityContext, inject } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { marked } from 'marked';
@Component({ /* ... */ })
export class MarkdownMessageComponent {
// inject() dans un champ = contexte d'injection valide (pas de NG0203)
private readonly sanitizer = inject(DomSanitizer);
async render(text: string): Promise<SafeHtml> {
// marked.parse() peut renvoyer string | Promise<string> selon la config — on await
const rawHtml = await marked.parse(text);
return this.sanitizer.sanitize(SecurityContext.HTML, rawHtml) ?? '';
}
}Le LLM peut être amené (prompt injection) à produire <img onerror=...> ou <script> — le sanitizer Angular est ta dernière ligne de défense XSS.
🏋️ Exercices
Progression : implémenter → durcir pour la prod → casser puis réparer. Fais-les dans l'ordre.
1. Migrer un mini-module en standalone (implémenter)
Objectif : transformer un FeatureModule (1 composant + 1 directive + 1 pipe + 1 service + 1 route lazy) en 100% standalone, sans aucun NgModule. Indice : remplace declarations par imports sur chaque consommateur, passe le service en providedIn:'root', et la route en loadComponent. Vérifie qu'ng build produit un chunk lazy séparé (--stats-json + source-map-explorer).
2. Guard + resolver + interceptor fonctionnels, 100% testés (implémenter → durcir)
Objectif : un roleGuard(role) factory, un entityResolver<T> générique, et un retryInterceptor qui rejoue les 5xx une fois ; chacun testé via TestBed.runInInjectionContext sans instancier de classe DI. Indice : roleGuard retourne (): CanActivateFn. retryInterceptor utilise next(req).pipe(retry({ count: 1, delay: ... })) mais ne retry pas les méthodes non-idempotentes (POST) — checke req.method.
3. Provider hiérarchique : un store par onglet (durcir → prod)
Objectif : un TabStateService provisionné au niveau @Component d'un <app-tab>, de sorte que chaque onglet ait son état isolé, détruit à la fermeture. Prouve via un test que deux instances ne partagent pas d'état. Indice : providers: [TabStateService] sur le composant (pas providedIn:'root'). Injecte DestroyRef dans le service pour logger sa destruction et vérifie qu'elle a lieu à la fermeture de l'onglet.
4. Streaming de chat IA zoneless, sans jank (durcir → prod)
Objectif : implémenter le ChatService de la section IA, mais mesure-le : 300 tokens à 150 tok/s doivent rester ≤ 60 re-renders/s et garder l'input réactif. Indice : sans le requestAnimationFrame coalescing, un messages.update([...m]) par token écroule la frame rate. Profile avec Performance tab de Chrome ; compare avec et sans rAF.
5. Casser puis réparer : NG0203 et le piège de l'await (casser → réparer)
Objectif : écris délibérément un service dont une méthode async load() appelle inject(HttpClient) après un await. Constate le NG0203. Répare de deux façons et explique le tradeoff. Indice : réparation A — capture inject(HttpClient) dans un champ (idiomatique, recommandé). Réparation B — runInInjectionContext(this.envInjector, () => ...) (nécessaire seulement si le code injecté vient d'un import() dynamique dont tu ne contrôles pas les champs).
6. Casser puis réparer : le singleton fantôme (casser → réparer)
Objectif : déclare un CacheService à la fois en providedIn:'root' ET dans providers: [CacheService] d'un composant enfant. Observe que le cache « ne marche pas » (deux instances). Répare et explique la remontée d'arbre. Indice : retire le providers local ; ou si l'isolation est voulue, assume-la et documente-la. Utilise inject(CacheService, { skipSelf: true }) pour prouver lequel est résolu.
🎤 En entretien
Q : Quelle est la différence entre EnvironmentInjector et ElementInjector, et dans quel ordre inject() les consulte ? R : inject() remonte d'abord la chaîne des ElementInjector (composant courant → ancêtres DOM), puis bascule sur les EnvironmentInjector (route lazy → root → platform) avant de throw NG0201. Le premier provider trouvé gagne — c'est pourquoi un providers local masque un singleton root pour son sous-arbre.
Q : inject() est-il plus performant que le constructeur DI ? R : Non, ils compilent vers le même code, zéro différence runtime. Le gain est l'ergonomie : utilisable hors classes (guards/resolvers/interceptors fonctionnels), pas de cascade super() en héritage, readonly et inférence triviaux. On migre pour le DX et le tree-shaking des guards fonctionnels, pas pour la vitesse.
Q : Pourquoi inject() lève NG0203 après un await, et comment le résoudre proprement ? R : Le contexte d'injection n'existe que pendant la construction synchrone du champ/de la classe ; après un await la stack a quitté ce contexte. Solution idiomatique : capturer les deps dans des champs (résolus à la construction). En dernier recours, runInInjectionContext(envInjector, fn) pour du code injecté dynamiquement.
Q : Comment isoler l'état d'un service par instance de composant tout en gardant le reste en singleton ? R : providers: [X] sur le @Component crée une instance par instance de composant via l'ElementInjector, détruite avec la vue (idéal pour form/wizard/sélection) ; tout le reste reste providedIn:'root'. Risque : oublier que c'est local et s'attendre à un singleton (state qui « disparaît » entre deux vues), ou l'inverse (state qui « persiste » à tort) — d'où l'importance de raisonner la portée explicitement.
🔗 Liens
- Angular Docs — Standalone components
- Angular Docs — Dependency injection
- Angular Blog — Standalone moves to default in v19
- RFC : inject() function (GitHub angular/angular)
- Migration schematic
📌 Récap final
- Standalone = chaque composant déclare ses imports et ses providers, plus de
NgModuleobligatoire ; défaut depuis Angular 17, implicite depuis 19. bootstrapApplicationremplaceplatformBrowserDynamic().bootstrapModule(AppModule)et accepte un tableau de providers (provideRouter,provideHttpClient, etc.).inject()complète (et tend à remplacer) le constructeur DI ; utilisable dans toute fonction qui tourne dans un contexte d'injection (guards, resolvers, interceptors fonctionnels, factories de tokens).- Hors contexte (après
await, dans un timer), il fautrunInInjectionContext(envInjector, () => …). - Les schematics (
ng generate @angular/core:standalone) automatisent 90% de la migration ; le reste est principalement du test et de la coexistence à orchestrer. - En 2026, démarrer toute nouvelle base Angular en 100% standalone, et planifier la disparition des
NgModulelegacy.