skip to content

An Angular dialog, opened from a lazy route with createComponent, throws NG0201 for a service in that route's providers, though the route's components inject it fine — why, and how do you fix it?

level: seniorimportance: should knowfreq 38%

answer

  1. who created the component, with what
  2. which environment injector was passed
  3. route injectors are children of root
  4. environmentInjector and elementInjector options

basics

~10 s

The dialog was created with the root environment injector (for example ApplicationRef.injector), which cannot see the route's child injector. Pass the route's EnvironmentInjector (inject(EnvironmentInjector) in the opener) to createComponent, or provide the service higher.

solid answer

~40 s

Route `providers` live in a **child environment injector** the router creates for that route; components the router activates get it as their environment injector, which is why they resolve the service. A dialog created with `createComponent(Dialog, {environmentInjector: appRef.injector, hostElement})` gets `root` instead, and it is attached to `document.body`, so it has no element ancestors either: its walk goes root, platform, `NullInjector` and throws `NG0201`. The fix is to create it with the injector of the context that owns the service — `inject(EnvironmentInjector)` in the routed opener, plus `elementInjector: inject(Injector)` if it also needs the opener's component providers. Moving the service to `root` also works but changes its scope. A root-level `DialogService` that injects the route service fails the same way, for the same reason.

code

ts · 30 lines
ts
import {
  ApplicationRef, Component, EnvironmentInjector, Injector, Service, createComponent, inject,
} from '@angular/core';

@Service({autoProvided: false}) // listed in the 'reports' route's providers
export class ReportFilters {
  period = 'Q3';
}

@Component({selector: 'app-export-dialog', template: `Exporting {{ filters.period }}`})
export class ExportDialog {
  filters = inject(ReportFilters);
}

@Component({selector: 'app-report-page', template: `<button (click)="open()">Export</button>`})
export class ReportPage {
  private appRef = inject(ApplicationRef);
  private env = inject(EnvironmentInjector); // the route's environment injector
  private injector = inject(Injector); // this component's element injector

  open() {
    const hostElement = document.body.appendChild(document.createElement('div'));
    const ref = createComponent(ExportDialog, {
      hostElement,
      environmentInjector: this.env, // this.appRef.injector here throws NG0201
      elementInjector: this.injector, // optional: also see ReportPage's providers
    });
    this.appRef.attachView(ref.hostView);
  }
}

go deeper

for a junior

Recall that route providers are only visible to that route's components, and that a component created in code only sees the injectors it was given.

for a middle

Explain that lookups only go up from the injector a component was created with, and that route injectors are children of root, not part of it.

for a senior

Diagnose from the NG0201 path which injector the dialog was created with, pass the opener's EnvironmentInjector or elementInjector, and manage the lifetime of any manual injector.

for a principal

Design shared overlay and dialog infrastructure so callers pass their injection context by default, keeping feature state scoped to routes without forcing everything into root.

## The setup A lazily loaded reporting feature declares its state on the route: ```ts {path: 'reports', providers: [ReportFilters], loadChildren: () => import('./reports.routes')} ``` Every component the router renders under `reports` can `inject(ReportFilters)`. Then someone adds an "Export" dialog, created programmatically so it can sit on `document.body` above everything else — and it throws `NG0201: No provider found for \`ReportFilters\``, with a path ending in `ExportDialog -> ReportFilters`. ## Why the routed components work - When a route declares `providers`, the router creates a **child environment injector** for it, with the parent route's injector (ultimately `root`) as its parent. The same injector serves the route's children, guards and resolvers. - When the router activates a component in a `<router-outlet>`, it creates the component with **that** environment injector and with the outlet's location as its element parent. - So a routed component's walk is: element injectors (its own, then the outlet's ancestors), then the **route injector**, then parent route injectors, `root`, platform, `NullInjector`. `ReportFilters` is found at the route injector. ## Why the dialog fails A programmatically created component gets exactly the injectors you pass it. The standalone `createComponent()` requires an `environmentInjector` and accepts an optional `elementInjector`; `ViewContainerRef.createComponent` accepts `injector` and `environmentInjector` options. A typical dialog helper written once for the whole app passes the application's injector: ```ts createComponent(ExportDialog, {hostElement, environmentInjector: this.appRef.injector}); ``` That walk is: the dialog's own element injector, no element ancestors (it is attached to `document.body`), then `root`, platform, `NullInjector`. The route injector is a **child** of `root`, and lookups only go **up**, never down into children — so `ReportFilters` is invisible, and resolution throws. The same failure appears without any dialog when a `@Service()` / `providedIn: 'root'` service injects `ReportFilters`: root-created services resolve their dependencies from root upward. ## Diagnosing it 1. Read the `Path:` in the NG0201 message — the last entry (`ReportFilters`) is the missing token and the entry before it (`ExportDialog`) is whose injector context failed. 2. Find where the token is provided — here, a route's `providers`. 3. Find which environment injector the failing component or service was created with — `appRef.injector`, an injector captured in a root service, or the one the router passed. 4. Check the parent relation: the providing injector must be the creating injector **or one of its ancestors**. 5. Angular DevTools' injector tree view shows both hierarchies and where each provider sits, which confirms steps 2-4 quickly. ## Fixes, in order of preference | Fix | When it fits | Cost | |---|---|---| | Pass `inject(EnvironmentInjector)` from the routed opener as `environmentInjector` | The dialog belongs to the feature | The opener must supply its injector | | Also pass `elementInjector: inject(Injector)` | The dialog needs the opener's component providers too | Dialog tied to one opener instance | | Create a dedicated child with `createEnvironmentInjector([...], routeInjector)` | The dialog needs extra providers of its own | You must call `destroy()` on it when the dialog closes | | Move the service to `root` | The state is genuinely app-wide | Loses per-feature isolation and lazy loading | Inside a routed component, `inject(EnvironmentInjector)` resolves to the environment injector that component was created with — the route's injector when the route (or an ancestor route) declares providers — so the dialog then walks route injector, `root`, platform, and finds `ReportFilters`. ## What not to do - Do not add `ReportFilters` to the dialog's own `providers`: the dialog would get a **fresh, empty** instance and show filters the page never set. - Do not make the shared dialog helper a root service that injects feature state; pass the caller's injector in as an argument instead. - Remember that a manually created environment injector is not tied to any component lifecycle: destroy it, or it and its instances outlive the dialog. - Do not "fix" it by catching the error with an optional lookup: the dialog would then run with `null` filters and fail later, far from the cause. The general rule the incident teaches: **a component or service sees exactly the injectors it was created with and their ancestors.** Route injectors are children, never part of `root`, so anything created from `root` — shared dialog helpers, root services, components attached with `ApplicationRef.injector` — cannot see feature-scoped providers until it is handed the feature's injector.

  • When would you create a dedicated injector with createEnvironmentInjector for the dialog, and what must you remember?
    When the dialog needs providers of its own — a per-dialog store, a different config token — on top of the route's. Create it with `createEnvironmentInjector([DialogStore], routeInjector)` so the route's providers remain reachable through its parent. It is not tied to any component, so call its `destroy()` when the dialog closes, or its instances and `ngOnDestroy` cleanup never run.
  • Why not simply make ReportFilters providedIn root?
    It would fix the NG0201, but the service would become an application-wide singleton: its state would survive leaving the reports feature, be shared with any other screen, and its code would no longer be tied to the lazy chunk's lifetime. If the state is feature-scoped, keep it on the route and pass the right injector instead.

A caterer (the dialog) hired through head office gets head office's supply room; the marketing floor's storeroom (route providers) is only open to people badged onto that floor. Give the caterer the marketing floor's badge (its EnvironmentInjector) and the supplies are reachable.

saying these in an interview costs you the question

  • Lazy route providers are merged into the root injector
  • Adding the service to the dialog's providers shares the page's state
  • Environment lookups can search child injectors, not just parents
  • ApplicationRef.injector sees every route's providers
  • An injector made with createEnvironmentInjector cleans up automatically