Skip to content

Angular NG0203: Fix inject() Injection Context Errors

NG0203: inject() must be called from an injection context stops your app cold. Here's exactly why it happens and the three real fixes.

angular angular-errors ng0203 dependency-injection inject standalone-components signals typescript
Bharath G
Reading Progress

On This Page

1. The Error

You call inject() somewhere convenient — inside ngOnInit, inside a setTimeout, inside an RxJS subscribe callback — and Angular throws immediately:

ERROR RuntimeError: 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`
    at assertInInjectionContext (core.mjs:1892:11)
    at injectInjectorOnly (core.mjs:1856:9)
    at Module.ɵɵinject (core.mjs:1868:20)
    at inject (core.mjs:24587:20)
    at UserService.loadPreferences (user.service.ts:14:19)
    at UserComponent.ngOnInit (user.component.ts:11:23)
    at callHook (core.mjs:8291:14)
    at callHooks (core.mjs:8253:17)
    at executeInitAndCheckHooks (core.mjs:8204:9)
    at refreshView (core.mjs:10432:21)

In dev mode Angular also prints the doc link so you can read the canonical explanation:

Error: 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`. Find more at https://angular.dev/errors/NG0203

There is no --configuration production variant that "goes away." Unlike NG0100, which is a dev-only double-check that gets stripped from optimized builds, NG0203 is a hard runtime assertion Angular keeps in every build — because without an active injector, inject() has no way to resolve a token at all. In production the stack is minified (core.mjs frames collapse to single-letter function names and the message loses the doc link if you've stripped error messages), but the throw still happens, every time, at the same line.

The wording above is current for Angular v17 through v22 (verified against angular.dev/errors/NG0203 on the v22.1 docs) — the message text has not changed since inject() was generalized for standalone code in v15. What has changed is how often you hit it: v19 made standalone: true the default, which means far more services and utility functions are written as free functions that call inject(), and v21's zoneless-by-default apps push more logic into callbacks (effect, afterNextRender, subscriptions) that run outside the original construction call stack — exactly the shape that trips this error.

2. How to Reproduce It

npx @angular/cli@22 new repro --defaults --ssr=false
cd repro

package.json dependency block (pinned, for reproducibility):

{
  "dependencies": {
    "@angular/core": "^22.1.0",
    "@angular/common": "^22.1.0",
    "@angular/platform-browser": "^22.1.0",
    "rxjs": "~7.8.0",
    "tslib": "^2.3.0"
  },
  "devDependencies": {
    "@angular/cli": "^22.1.0",
    "@angular/build": "^22.1.0",
    "typescript": "~5.9.0"
  }
}

Node 20.19+ or 22.12+ (Angular v22's minimum). No zone.js dependency is needed for this repro — it fails identically zoneless or zone-based.

src/app/user.service.ts:

import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Injectable({ providedIn: 'root' })
export class UserService {
  loadPreferences() {
    // Called later, outside construction — this is the bug.
    const http = inject(HttpClient);
    return http.get('/api/preferences');
  }
}

src/app/user.component.ts:

import { Component, OnInit } from '@angular/core';
import { UserService } from './user.service';

@Component({
  selector: 'app-user',
  standalone: true,
  template: `<p>Preferences loading…</p>`,
})
export class UserComponent implements OnInit {
  private userService = new UserService(); // also wrong, see below
  // (In real apps this is usually `private userService = inject(UserService);`
  // and the class instantiates fine — the crash happens one call deeper,
  // inside loadPreferences(), the first time it actually runs.)

  ngOnInit() {
    this.userService.loadPreferences(); // <-- throws NG0203 here
  }
}

src/app.config.ts:

import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient()],
};

Run it:

npm install
npx ng serve

Open the app and watch the console: ngOnInit runs (inside an injection context, because Angular activates one for the whole refreshView/hook-calling pass), but loadPreferences() calls inject(HttpClient) on the second call frame — and by the time that line executes, Angular has already deactivated the injection context. This is the single most common shape of NG0203: a method that calls inject() internally, invoked from somewhere that isn't itself directly inside construction.

The other extremely common trigger — same error, different repro — is calling inject() inside a setTimeout, a Promise.then, or an RxJS subscribe:

export class UserComponent {
  private http = inject(HttpClient); // fine here

  ngOnInit() {
    setTimeout(() => {
      const router = inject(Router); // NG0203 — callback runs on a macrotask,
    }, 0);                           // long after any injection context existed
  }
}

3. Version Behaviour Matrix

VersionBehaviour
v17inject() usable in standalone components/services/functional guards; NG0203 wording as above.
v18Same. effect() (developer preview) adds a new NG0203 trigger: creating an effect outside an injection context.
v19standalone: true becomes the default — far more code is written as free functions calling inject(), raising incidence. input()/model()/signal queries (also injection-context-bound) ship.
v20Signals APIs stable; resource() experimental. No message change.
v21Zoneless is the default for new apps — more logic moves into scheduler callbacks and effect()s, a common new source of NG0203 when an effect factory is built inside another callback. Vitest becomes the default test runner, changing how you reproduce this in specs (see §8).
v22 (current, 22.1.x)No change to NG0203 itself. Angular DevTools' DI graph (stable) lets you inspect which injector was active — useful for confirming why a given call site has no context, covered in §5.

This error is not version-gated the way NG0100 or hydration codes are — the injection-context rule has been stable since inject() went general-purpose. The moving part across releases is how much of your code now runs through inject() (standalone default, signal inputs/queries, effect(), resource(), functional guards/interceptors/resolvers all use it), which is why it shows up more with every major version even though the rule itself hasn't changed.

Ecosystem note: @angular/core@22 requires TypeScript ~5.9.0 and RxJS ~7.8.0; using inject() in a library targeting ng-packagr compiled with an older core will throw the same NG0203 if the library's factory functions are inlined incorrectly — check angular.dev/reference/versions before mixing library and app major versions.

4. Why It Happens — Surface Level

inject() is not a magic global lookup. It only works while Angular has an "active injector" set — which is true only during: constructing a class (running its constructor, which is where field initializers execute), running a DI factory function (useFactory, functional guards/interceptors/resolvers, provideAppInitializer), or running inside runInInjectionContext(injector, fn). The instant that call stack returns, Angular clears the active injector. Call inject() one tick, one setTimeout, one .then(), or one method call removed from that window, and there is nothing for it to read from — so it throws rather than silently returning undefined or the wrong instance.

5. Why It Happens — Under the Hood

Angular tracks "am I in an injection context" with a single module-level pointer, not a call-stack introspection trick. Internally (packages/core/src/di/injector_compatibility.ts in the Angular source), there's a variable that holds the currently active Injector (or null). Three things set it:

  • Class instantiation. When Ivy instantiates a directive, component, pipe, or injectable via its generated factory (ɵfac), it calls ɵɵdirectiveInject/ɵɵinject internally, and the surrounding instantiation logic (R3Injector.hydrate / NodeInjector) sets the active injector for the duration of that factory call. Because TypeScript compiles field initializers into the top of the constructor body, private x = inject(Foo) runs inside that window — it looks like "not a constructor" but structurally it is one.
  • Factory functions Angular itself invokes. useFactory providers, functional CanActivateFn/HttpInterceptorFn/ResolveFn, and provideAppInitializer callbacks are all called by Angular with the context pre-set, which is why inject() works inside them even though they look like plain functions.
  • runInInjectionContext(injector, fn). This is the manual escape hatch: it pushes injector as active, calls fn() synchronously, and restores whatever was active before — even if that was null. It's implemented as a simple save/set/call/restore, not a context that persists across microtask boundaries.

assertInInjectionContext (the function you see in the stack trace) is the guard every call to inject() runs through first: it reads that pointer, and if it's null, throws NG0203 immediately, before attempting any token resolution. That's why the stack trace bottoms out at injectInjectorOnlyassertInInjectionContext rather than anywhere near your actual dependency graph — the error fires before DI even starts walking the injector tree, which is also why NG0203 never shows an R3InjectorError chain the way a missing-provider error (NG0201) does. There's nothing to walk yet.

This is also exactly why effect() and toObservable()/toSignal() need an injection context when created (they call inject(DestroyRef) internally to register cleanup) but not when they later run — the DI call happens once, at creation time, inside whatever context created them, and the closure keeps a reference to what it already resolved.

You can see this live in Angular DevTools' DI graph (stable since v22): select a component instance and it shows you the exact injector chain that was active when its constructor ran. If a service method throws NG0203, DevTools won't show you anything for that call — because by definition no injector was active — which itself is a useful diagnostic ("DevTools shows nothing here" confirms you're outside any context, rather than pointing at the wrong injector).

6. The Fix

Fix 1 — move the inject() call to where construction actually happens. This is correct whenever the dependency doesn't change between calls:

 @Injectable({ providedIn: 'root' })
 export class UserService {
-  loadPreferences() {
-    const http = inject(HttpClient);
-    return http.get('/api/preferences');
-  }
+  private http = inject(HttpClient);
+
+  loadPreferences() {
+    return this.http.get('/api/preferences');
+  }
 }

Fix 2 — capture the injector and re-enter it explicitly, for the case where you genuinely need inject() inside a callback that Angular didn't call for you (a setTimeout, a third-party SDK callback, a Promise continuation):

 export class UserComponent {
   private http = inject(HttpClient);
+  private injector = inject(Injector);

   ngOnInit() {
-    setTimeout(() => {
-      const router = inject(Router); // NG0203
-      router.navigate(['/home']);
-    }, 0);
+    setTimeout(() => {
+      runInInjectionContext(this.injector, () => {
+        const router = inject(Router);
+        router.navigate(['/home']);
+      });
+    }, 0);
   }
 }

Injector and Router need to be imported from @angular/core and @angular/router respectively, and RouterModule/provideRouter must already be configured — this fix doesn't work around a missing provider, only a missing context.

Fix 3 — restructure so the callback doesn't need inject() at all, which is usually the right fix, not just a workaround: resolve every dependency up front in the constructor/field initializers, and have the callback close over already-resolved instances:

 export class UserComponent {
   private http = inject(HttpClient);
-
-  ngOnInit() {
-    setTimeout(() => {
-      const router = inject(Router);
-      router.navigate(['/home']);
-    }, 0);
-  }
+  private router = inject(Router);
+
+  ngOnInit() {
+    setTimeout(() => this.router.navigate(['/home']), 0);
+  }
 }

Fix 2 is the one to reach for only when the token genuinely must be resolved lazily (for example, resolving a Router from a different injector than the component's own, inside a lazily loaded microfrontend boundary). Reaching for runInInjectionContext as a default habit is a smell: it's a correct API, but if every callback in your codebase needs it, the class's dependencies aren't actually being declared where a reader expects them — at the top of the class.

7. Best Practices & The Better Design

The pattern that avoids this class of bug entirely: inject everything a class needs at the top, as field initializers, and never call inject() past that point. inject() was designed to be called exactly like a constructor parameter — once, at construction — just with function syntax instead of parameter syntax. Treat any inject() call inside a method body as a sign the dependency should have been a field.

@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpClient);
  private destroyRef = inject(DestroyRef);

  loadPreferences() {
    return this.http.get('/api/preferences');
  }
}

For genuinely deferred work tied to a component's lifetime — a subscription that must stop when the component is destroyed — prefer takeUntilDestroyed() (which itself needs to be called inside an injection context, so declare it as a field-initializer-adjacent constant, not inside the subscribing callback):

export class UserComponent {
  private http = inject(HttpClient);
  private destroyRef = inject(DestroyRef);

  ngOnInit() {
    this.http.get('/api/preferences')
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(prefs => this.applyPreferences(prefs));
  }
}

takeUntilDestroyed(this.destroyRef) works here because you're passing an already-resolved DestroyRef, not calling inject() inside the callback — the injection-context requirement is satisfied at the call site that constructs the operator, not at subscribe time.

8. How to Prevent It Long-Term

Add an ESLint pass that flags inject( calls outside a class's property/constructor position — @typescript-eslint's no-restricted-syntax rule can match the AST shape (CallExpression[callee.name='inject'] not inside a PropertyDefinition or constructor) even without a dedicated Angular rule. Keep ng lint and a real ng build --configuration production in CI, not just ng serve — NG0203 in a rarely-hit code path (an error handler, an admin-only screen) can sail past manual testing and only surface for a user in production. When testing services in isolation, don't call the method directly from a bare new Service() — use TestBed and, for anything that calls inject() outside construction on purpose, wrap the assertion in TestBed.runInInjectionContext(() => ...) so the spec fails the same way production does rather than passing accidentally. If you're on Vitest (the v21+ default runner), the same TestBed.runInInjectionContext API applies unchanged. Finally, when reviewing PRs, treat "this helper method calls inject() internally" as a request-changes comment by default — ask why the dependency isn't a field, the same way you'd ask why a constructor parameter was smuggled into a method signature instead.

9. Key Takeaways

  • NG0203 fires the instant inject() runs with no active injector — before Angular even attempts to resolve the token, which is why there's no R3InjectorError chain, only a bare assertion.
  • An injection context exists only during class construction (including field initializers), a DI-invoked factory function, or inside runInInjectionContext. It does not survive a setTimeout, a Promise.then, or an RxJS subscribe callback.
  • The message text has been stable since v15; what's changed release over release is exposure — standalone-by-default (v19) and zoneless-by-default (v21) both push more code through inject()-calling paths.
  • The durable fix is almost always to resolve dependencies as field initializers and have methods use this.dep, not to sprinkle runInInjectionContext at every call site that breaks.
  • When you do need a deferred inject(), capture an Injector reference during construction and re-enter it explicitly — never assume a callback inherits the context of the code that scheduled it.
angularangular-errorsng0203dependency-injectioninjectstandalone-componentssignalstypescript

From aspiring developer to blogger, I test learning platforms, simplify programming syntax, and share resources that work. Helping you code smarter as I grow myself. New or experienced, you're welcome here.

Comments