Skip to content

Standalone components & inject()

TL;DR — Depuis Angular 14 (preview) et stable en 15, le concept de NgModule n'est plus obligatoire. Un composant standalone se déclare directement comme l'unité de composition : il importe ses dépendances dans imports: [...], est bootstrappé via bootstrapApplication, et expose ses providers de façon hiérarchique. La fonction inject() 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

ts
// 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(['/']);
  }
}
ts
// user.service.ts
import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class UserService {
  readonly name = signal('Anonyme');
}
ts
// 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));
ts
// 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.ts

Le fichier app.config.ts centralise la configuration :

ts
// 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])),
  ],
};
ts
// 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()

ts
// 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é

ts
// 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)

ts
// 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()

ts
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

ts
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')

ts
@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()

ts
// 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

ts
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

VersionApport 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

  1. inject() hors contexte d'injection — Erreur NG0203: inject() must be called from an injection context. Cause typique : appel après un await, à l'intérieur d'un setTimeout, dans un callback RxJS. Solution : capturer les dépendances au plus tôt (champs de classe) ou utiliser runInInjectionContext.
  2. Import oublié — Avec standalone, si tu utilises *ngIf ou NgClass, il faut importer la directive elle-même (ou CommonModule). 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.
  3. Providers dupliqués — Un provider défini à la fois dans providers au 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).
  4. 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.
  5. 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).
  6. inject() dans une classe abstraite parente — Marche bien, contrairement à un constructeur où il faut passer les dépendances via super(...). C'est un des gros gains d'inject().
  7. 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.
  8. Schematic de migration sur monorepong generate @angular/core:standalone migre tout, mais peut casser les libs internes si elles dépendent encore d'NgModule exporté. Migrer par feature.
  9. Tests qui dépendent de NgModuleTestBed.configureTestingModule({ declarations: [...] }) ne marche pas pour un composant standalone : il faut le mettre dans imports.
  10. HostBinding/HostListener vs host: {} — Indépendant de standalone, mais on tombe souvent dessus en migrant. Privilégier host: {} en 17+ (et @HostBinding reste valide).
  11. bootstrapApplication vs platformBrowserDynamic — 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.
  12. @Injectable() sans providedIn — Toujours valide, mais alors il faut le mettre dans un providers: [...] quelque part. Oublier les deux donne NullInjectorError.
  13. 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

ts
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

ts
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

ts
TestBed.overrideProvider(API_BASE_URL, { useValue: 'http://mock' });

Tester un interceptor fonctionnel

ts
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

ts
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.

ts
@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.

ts
// 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;
  }
}
ts
// 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']);
  };
ts
// 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);
};
ts
// 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']));
}
ts
// 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),
  },
];
ts
// 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 fonctionnelsInté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 :

  1. Le constructeur d'une classe injectable (composant, directive, pipe, service décoré).
  2. L'initialisation d'un champ de cette même classe (x = inject(...)).
  3. Une factory d'InjectionToken ou d'un provider useFactory.
  4. Un effect() créé dans un contexte d'injection.
  5. Un guard, resolver ou interceptor fonctionnel (Angular les exécute via runInInjectionContext).
  6. 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.

ts
// 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 :

ts
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éeOù déclarerInstance(s)Tree-shakableCas typique
Singleton global@Injectable({ providedIn: 'root' })1Services métier, HTTP, store global
Une par sous-arbre lazyprovidedIn: 'platform' / providers de route1 par EnvironmentInjector lazypartielState isolé d'une feature lazy
Une par instance de composantproviders: [X] sur le @ComponentN (1/instance)Form state, drag controller, sélection locale
Une par instance de directiveproviders sur la directiveNTooltip controller, validateur
Jamais en singleton rootprovidedIn: 'any'1 par injecteur lazy + 1 rootpartielRare ; 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 avec source-map-explorer dist/**/*.js après ng build --stats-json.
  • Piège de perf observé en prod : un service providedIn: 'root' qui fait du travail lourd dans son champ inject() (ex. parser un gros JSON) s'exécute au premier inject, pas au bootstrap — diffère bien, mais ne le mets pas dans un champ d'AppComponent sinon 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).

ts
// 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')
  }
}
ts
// 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é par requestAnimationFrame → 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 (le messages.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 le fetch. Le navigateur ferme la socket, et un backend NestJS bien fait écoute req.on('close') / passe le AbortSignal à 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.
  • generationId envoyé 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, forRootAsync pour un client Anthropic DIّ, 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 :

ts
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


📌 Récap final

  • Standalone = chaque composant déclare ses imports et ses providers, plus de NgModule obligatoire ; défaut depuis Angular 17, implicite depuis 19.
  • bootstrapApplication remplace platformBrowserDynamic().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 faut runInInjectionContext(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 NgModule legacy.

Bibliothèque tech perso — Achref