The app shows a blank screen and the console says one of these, depending on your Angular version:
ERROR NullInjectorError: R3InjectorError(Environment Injector)[_ProductApi -> _HttpClient -> _HttpClient]:
NullInjectorError: No provider for _HttpClient!ERROR ɵNotFound: NG0201: No provider found for `_HttpClient`. Source: Environment Injector. Path: _ProductApi -> _HttpClient. Find more at https://v20.angular.dev/errors/NG0201The first is Angular 19 (and earlier), the second Angular 20 and later; both were captured from a fresh CLI project. They mean the same thing: something called inject(X) (or asked for X in a constructor), Angular walked up the injector tree looking for a provider of X, reached the end (the NullInjector) and found none. The leading underscore on class names comes from the dev build; your class is HttpClient.
Quick fix checklist
- Find the last token in the path or brackets: that is the missing provider. The tokens before it are the chain that requested it.
HttpClientmissing on Angular 19 or 20: addprovideHttpClient()toprovidersinapp.config.ts(Angular 21+ providesHttpClientby default, but you still call it to add interceptors).- Your own service missing: add
@Injectable({ providedIn: 'root' }), or list it in theprovidersof a component or route that encloses every consumer. - An
InjectionTokenmissing: add{ provide: TOKEN, useValue: ... }to the app config, or give the token afactory. - In a unit test (
Source: DynamicTestModule): add the same providers toTestBed.configureTestingModule. - A library service (
Store,MatDialog,TranslateService): call that library’sprovideX()function inapp.config.ts.
Before you start
Know where your app registers providers. A standalone app (the default since Angular 19, where standalone: true is implied) bootstraps with bootstrapApplication(App, appConfig), and appConfig.providers is the root environment injector. Older apps use @NgModule({ providers: [...] }) on AppModule, which plays the same role.
You should also know the two injector trees. Element injectors come from the providers arrays on components and directives and follow the DOM. Environment injectors come from the app config, routes with providers, and lazy-loaded boundaries. Angular searches the element injectors first, then the environment injectors, then gives up. Hierarchical providers covers the lookup rules in depth.
Why it happens
A provider is a recipe that tells an injector how to create a value for a token. A token is usually a class, sometimes an InjectionToken. inject(ProductApi) does not create a ProductApi on the spot; it asks the nearest injector for one, which either returns a cached instance, runs the recipe, or delegates to its parent.
Three situations break the chain:
- No recipe anywhere.
@Injectable()withoutprovidedIn, a token nobody provides, or a library whoseprovideX()call was never added. - The recipe is in a branch the consumer cannot see. A service listed in
providerson<app-checkout>is invisible to its sibling<app-order-summary>, because element injectors only look upward. The same applies to a lazy route’sproviders: components outside that route cannot reach them. - A transitive dependency is missing.
ProductApiis provided in root, but it injectsHttpClient, which is not. The error namesHttpClienteven though your code asked forProductApi, which is why the path matters.
HttpClient was the classic case for years: it lives behind provideHttpClient() (formerly HttpClientModule). Since Angular 21, HttpClient is provided in root by default, so a bare inject(HttpClient) works; on 19 and 20 it still fails.
Step-by-step walkthrough
Step 1: Reproduce with the smallest chain
import { Component, Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class ProductApi {
private http = inject(HttpClient);
}
@Component({ selector: 'app-product-list', template: `<p>products</p>` })
export class ProductList {
private api = inject(ProductApi);
}With no provideHttpClient() in app.config.ts, this produces exactly the messages at the top of this page on Angular 19 and 20.
Step 2: Read the path
In the Angular 19 format, R3InjectorError(Environment Injector) names the injector where the failed lookup started. You may also see Standalone[_App], AppModule or DynamicTestModule there. Inside the brackets, read left to right: ProductApi asked for HttpClient, and the final HttpClient repeats because the lookup passed through one more injector (the null one) before failing.
In the Angular 20 format, Source: is the same injector and Path: is the same chain without the duplicate. When the component asked for the missing token directly, there is no Path at all: No provider found for `_CheckoutState`. Source: Environment Injector.
Neither format names the component that asked. To find it, open the stack trace and look for the frame just below inject, usually <instance_members_initializer> or a constructor in your bundle; with source maps, DevTools links it to the right file. Searching the codebase for inject(CheckoutState) is often faster.
Step 3: Decide where the provider belongs
Ask what lifetime the value should have:
- One instance for the whole app (API clients, auth state, caches):
@Injectable({ providedIn: 'root' }). Tree-shakable and needs no registration. - One instance per feature or route:
providers: [...]on the route definition. - One instance per component subtree (a wizard’s state, a form store):
providers: [...]on the component that encloses every consumer. - Configuration values: an
InjectionTokenprovided inapp.config.ts.
Step 4: Add the provider
// app.config.ts
import { ApplicationConfig, InjectionToken, provideZoneChangeDetection } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { authInterceptor } from './auth.interceptor';
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
{ provide: API_BASE_URL, useValue: 'https://api.example.com' },
],
};In an NgModule app the same lines go in AppModule’s providers; HttpClientModule is deprecated, so use provideHttpClient() there too. A missing token prints No provider for InjectionToken API_BASE_URL! (19) or No provider found for `InjectionToken API_BASE_URL` (20+). If a sensible default exists, put it on the token itself so it can never be missing:
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL', {
providedIn: 'root',
factory: () => '/api',
});Interfaces cannot be tokens because they vanish at runtime; wrap them in an InjectionToken<MyInterface>.
Worked scenario
A checkout page keeps wizard state in a component-scoped service, so each visit starts fresh.
@Injectable()
export class CheckoutState {
step = 1;
}
@Component({ selector: 'app-checkout', template: `<p>checkout</p>`, providers: [CheckoutState] })
export class Checkout {
state = inject(CheckoutState);
}
@Component({ selector: 'app-order-summary', template: `<p>summary</p>` })
export class OrderSummary {
state = inject(CheckoutState);
}
@Component({
selector: 'app-checkout-page',
imports: [Checkout, OrderSummary],
template: `<app-checkout /><app-order-summary />`,
})
export class CheckoutPage {}The page fails with NullInjectorError: R3InjectorError(Environment Injector)[_CheckoutState -> _CheckoutState] on 19 and NG0201: No provider found for `_CheckoutState`. Source: Environment Injector. on 20. Diagnosis: the provider exists, but on <app-checkout>. <app-order-summary> is a sibling, so its lookup goes to CheckoutPage, then to the environment injectors, and never visits its sibling.
Fix: move the provider to the closest common ancestor, which keeps the per-visit lifetime.
@Component({ selector: 'app-checkout', template: `<p>checkout</p>` })
export class Checkout {
state = inject(CheckoutState);
}
@Component({
selector: 'app-checkout-page',
imports: [Checkout, OrderSummary],
providers: [CheckoutState],
template: `<app-checkout /><app-order-summary />`,
})
export class CheckoutPage {}Making it providedIn: 'root' would also silence the error, but then the wizard state survives navigation and leaks into the next checkout.
Common mistake
Adding the service to every component’s providers. Each providers entry creates a new instance for that subtree. Two components that each list CartService get two separate carts, and updates in one never reach the other. Provide once, at the level whose lifetime you want.
Using inject(X, { optional: true }) to make the error go away. You get null instead of an error, and the crash moves to the first property access, far from the cause. Reserve optional for genuine optional dependencies.
Injecting route-scoped services from root services. A providedIn: 'root' service is created by the root injector, which cannot see providers registered on a lazy route or a component. If CartService (root) injects PromoRules (listed only in the /checkout route’s providers), it fails no matter which screen triggers it. Move the dependency up to root, or move the consumer down into the route.
Verify the behavior
A service test proves the dependency graph resolves:
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
describe('ProductApi', () => {
it('can be created with HTTP providers', () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
expect(TestBed.inject(ProductApi)).toBeTruthy();
});
});Without the providers line, Angular 20 fails this test with NG0201: No provider found for `_HttpClient`. Source: DynamicTestModule. followed by the path. With it, the test passes. In the running app, reload with DevTools open and confirm the console has no NG0201 or NullInjectorError, then exercise the screens that create the affected component.
Interview exercise
“A component two levels below CheckoutPage injects CheckoutState and works. A dialog opened from that component injects it too and gets NullInjectorError. Why, and how would you fix it?”
Answer and reasoning
Element injectors follow the tree a component is created in, not the component that triggered it. A dialog service such as MatDialog creates the dialog component with its own injector by default (usually the root one), so the lookup goes from the dialog to the environment injectors and never visits CheckoutPage’s providers. The error’s Source (an environment injector) confirms that the component provider was never seen.
Fixes, in order of preference: pass the opener’s injector to the dialog (MatDialog.open(Comp, { injector: this.injector }) or the viewContainerRef option), which keeps the per-checkout lifetime; pass the state as dialog data if the dialog only needs values; or move the provider to the route if the state really belongs to the whole route. Making it providedIn: 'root' works but changes the lifetime, which an interviewer will want you to notice.