~4 min de lecture
NG0203 : inject() hors contexte d'injection
Tu as migré tes constructeurs vers la fonction inject(). Plus court, plus typé, parfait pour composer des helpers. Tout marche en local. Tu déploies. Sentry te tombe dessus dans la demi-heure :
NG0203: inject() must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`.
Et là, surprise : la stack trace pointe vers un endroit que tu trouvais parfaitement légitime. Un setTimeout. Un subscribe. Un async/await. Tu ne comprends pas pourquoi le composant marchait il y a deux minutes et plus maintenant.
inject() n'est pas magique. C'est une fonction qui lit une variable globale interne d'Angular, l'injecteur courant. Cette variable n'est positionnée que pendant des fenêtres très précises : constructeur, factory, initializer de champ, et runInInjectionContext. Hors de ces fenêtres, l'injecteur est null et NG0203 tombe. Ce guide couvre les 4 scénarios qui font tomber l'erreur et la bonne manière de la corriger sans contourner l'API.
Comprendre le contexte d'injection en 30 secondes
Angular maintient un registre interne (_currentInjector) qui pointe vers l'injecteur actif. Quand tu instancies un service ou un composant, Angular pose son injecteur dans cette variable, exécute ton code, puis la remet à null. inject(MyService) ne fait qu'aller lire ce registre.
// Pseudo-code interne d'Angular
function inject<T>(token: ProviderToken<T>): T {
if (_currentInjector === null) {
throw new NG0203();
}
return _currentInjector.get(token);
}
Tant que tu restes synchrone et dans une zone "DI active", tout va bien. Dès que tu sors (callback différé, micro-task, observable), tu perds le contexte.
Piège 1 : inject() dans un callback différé
Tu factorisas un helper qui retarde une action :
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'app-checkout',
templateUrl: './checkout.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Checkout {
redirectAfterDelay() {
setTimeout(() => {
const router = inject(Router); // NG0203 ici
router.navigateByUrl('/thanks');
}, 2000);
}
}
À l'instant où le callback s'exécute, Angular a depuis longtemps remis l'injecteur à null. Tu es dans la macro-task du navigateur, plus du tout dans le constructeur du composant.
Correction : injecte au constructeur (ou comme champ), capture la référence dans une closure.
@Component({
selector: 'app-checkout',
templateUrl: './checkout.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Checkout {
private readonly router = inject(Router);
redirectAfterDelay() {
setTimeout(() => {
this.router.navigateByUrl('/thanks');
}, 2000);
}
}
Règle simple : inject() doit être appelé immédiatement, jamais derrière un délai. Si tu veux injecter dans un helper, fais-le au moment où le helper est créé, pas au moment où il est utilisé.
Piège 2 : inject() après un await
Celui-ci est vicieux parce qu'il marche tant que tu testes le chemin synchrone :
@Injectable({ providedIn: 'root' })
export class Hydration {
async hydrate() {
const config = inject(ConfigService); // ok, on est dans une factory
const data = await fetch(`/api/${config.tenant}`).then((r) => r.json());
const cache = inject(CacheService); // NG0203 en prod
cache.set('init', data);
}
}
Le premier inject() passe parce qu'au moment où la méthode commence, tu es encore dans la tâche synchrone qui a déclenché l'appel (souvent un APP_INITIALIZER ou un guard). Le second tombe parce que le await rend la main à l'event loop : à la reprise, l'injecteur est nul.
Le piège, c'est que ça peut marcher en dev (timings différents, dev tools qui maintiennent un contexte) et casser uniquement en SSR ou sous charge.
Correction : injecte tout en haut, avant tout await.
@Injectable({ providedIn: 'root' })
export class Hydration {
private readonly config = inject(ConfigService);
private readonly cache = inject(CacheService);
async hydrate() {
const data = await fetch(`/api/${this.config.tenant}`).then((r) => r.json());
this.cache.set('init', data);
}
}
Piège 3 : inject() dans un subscribe
Variante observable du piège 1. Le code dans le next s'exécute à des moments arbitraires, hors contexte :
@Component({
selector: 'app-cart',
templateUrl: './cart.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Cart {
private readonly cart$ = inject(CartStore).items$;
ngOnInit() {
this.cart$.subscribe(() => {
const analytics = inject(Analytics); // NG0203
analytics.track('cart_changed');
});
}
}
L'erreur ne se déclenche qu'à la première émission, donc tu peux la rater complètement en navigation directe et la voir uniquement quand un utilisateur ajoute un produit.
Correction : injecte une fois pour toutes, et passe à takeUntilDestroyed pour nettoyer.
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-cart',
templateUrl: './cart.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Cart {
private readonly cart = inject(CartStore);
private readonly analytics = inject(Analytics);
constructor() {
this.cart.items$
.pipe(takeUntilDestroyed())
.subscribe(() => this.analytics.track('cart_changed'));
}
}
Piège 4 : inject() dans une classe non-DI
Tu écris une petite classe utilitaire qui ne passe pas par Angular. Elle a besoin du Router. Tu tentes :
export class NavigationHelper {
constructor() {
this.router = inject(Router); // NG0203
}
private router: Router;
goHome() {
this.router.navigateByUrl('/');
}
}
Angular n'a jamais instancié cette classe. Tu fais new NavigationHelper() toi-même, donc aucun injecteur n'est positionné.
Deux options propres.
Option A : passer l'injecteur explicitement avec runInInjectionContext (utile pour bridger du code legacy ou tiers).
import { EnvironmentInjector, runInInjectionContext } from '@angular/core';
export class NavigationHelper {
private readonly router: Router;
constructor(injector: EnvironmentInjector) {
this.router = runInInjectionContext(injector, () => inject(Router));
}
goHome() {
this.router.navigateByUrl('/');
}
}
Et au point d'usage :
const injector = inject(EnvironmentInjector);
const helper = new NavigationHelper(injector);
Option B (presque toujours préférable) : transformer la classe en service.
@Injectable({ providedIn: 'root' })
export class NavigationHelper {
private readonly router = inject(Router);
goHome() {
this.router.navigateByUrl('/');
}
}
runInInjectionContext est un outil légitime, mais il est trop souvent utilisé comme rustine. Si tu en mets partout, c'est que ton architecture demande un service.
Bonus : forcer l'erreur tôt avec assertInInjectionContext
Quand tu écris un helper qui repose sur inject(), déclare explicitement qu'il doit être appelé dans un contexte d'injection. L'erreur tombe alors immédiatement, pas trois lignes plus loin avec un message moins lisible.
import { assertInInjectionContext, inject } from '@angular/core';
export function useTheme() {
assertInInjectionContext(useTheme);
const theme = inject(ThemeService);
return theme.current;
}
Et l'appel :
@Component({
selector: 'app-header',
templateUrl: './header.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Header {
protected readonly theme = useTheme();
}
Si quelqu'un appelle useTheme() depuis un setTimeout, l'erreur lui pointera explicitement vers useTheme, pas vers la ligne inject(ThemeService) enterrée trois niveaux plus bas.
Récap actionnable
inject()ne marche que dans un constructeur, un field initializer, une factory ou viarunInInjectionContext. Partout ailleurs, NG0203.- Tout callback différé (
setTimeout,subscribe,then,await) sort du contexte. Injecte avant, capture en closure. - Tes services et tes composants doivent appeler
inject()en premier, jamais après une instruction asynchrone. - Les classes que tu instancies à la main avec
newne sont pas dans le contexte. Passe par un@Injectableou parrunInInjectionContext(EnvironmentInjector, ...). - Tes helpers publics qui font du
inject()doivent commencer parassertInInjectionContext(yourFn). L'erreur sera lisible quand un dev fera l'inverse. - NG0203 est sournois en SSR et en prod : un timing différent peut révéler un bug invisible en dev. Si tu vois cette erreur seulement en prod, c'est presque toujours un
inject()planqué derrière unawaitou unsubscribe.
L'API inject() est superbe quand tu respectes son contrat. Ce contrat tient en une phrase : appelle-la tôt, capture la référence, et laisse les callbacks différés utiliser cette référence — pas l'API.