Providers & Injection Scopes
A provider tells Angular’s dependency-injection system how to create a value for a token, and where you register that provider decides how long the resulting instance lives and who gets to share it. Registering a service at the application root gives you a single shared singleton; registering it on a component or a route gives you a fresh, scoped instance tied to that part of the UI. Choosing the right scope is the difference between accidentally leaking state across your whole app and cleanly isolating it.
How providers map to scopes
Every Angular application is built from a tree of injectors. When a class asks for a dependency via inject() or a constructor parameter, Angular walks up that tree until it finds a provider for the requested token. The injector that owns the provider is the one that creates and caches the instance — so the location of the provider literally defines the scope.
| Registration site | Injector | Instances created | Typical use |
|---|---|---|---|
providedIn: 'root' | Root environment injector | One (singleton) | App-wide state, HTTP services, caches |
Route providers | Route environment injector | One per activated route subtree | Feature-scoped state |
Component providers | Element injector | One per component instance | Per-widget state, isolated config |
providedIn: 'platform' | Platform injector | One per platform | Multi-app shared services (rare) |
Root-level providers
The default and most common choice is providedIn: 'root'. The service becomes a singleton in the root injector, is available everywhere without any extra wiring, and is fully tree-shakable — if nothing injects it, the bundler drops it.
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CartService {
private readonly items = signal<string[]>([]);
readonly count = () => this.items().length;
add(sku: string): void {
this.items.update((list) => [...list, sku]);
}
}
Any component, guard, or other service can now inject it and they all see the same instance:
import { Component, inject } from '@angular/core';
@Component({
selector: 'app-add-button',
standalone: true,
template: `<button (click)="cart.add('SKU-1')">Add ({{ cart.count() }})</button>`,
})
export class AddButtonComponent {
protected readonly cart = inject(CartService);
}
Component-level providers
Listing a service in a component’s providers array creates a new instance for each instance of that component, stored in the component’s element injector. Child components nested in its template share that instance, but sibling instances of the same component each get their own. This is the right tool for per-widget state that must not bleed between instances.
import { Component, Injectable, inject, signal } from '@angular/core';
@Injectable()
export class PanelState {
readonly open = signal(false);
toggle() { this.open.update((v) => !v); }
}
@Component({
selector: 'app-panel',
standalone: true,
providers: [PanelState],
template: `
<button (click)="state.toggle()">Toggle</button>
@if (state.open()) {
<section><ng-content /></section>
}
`,
})
export class PanelComponent {
protected readonly state = inject(PanelState);
}
Notice the service is decorated with a bare @Injectable() (no providedIn) because its lifetime is owned by the component, not the root injector. Render two panels and toggling one leaves the other untouched — each owns a private PanelState.
Use a
viewProvidersarray instead ofproviderswhen you want the service visible to the component’s own template but not to content projected in via<ng-content>. It is a tighter scope.
Route-level providers
Lazy-loaded and even eagerly-loaded routes can declare a providers array. Angular creates an environment injector for that route’s subtree, so the service is a singleton for that feature and is destroyed when you navigate away. This gives you feature isolation without polluting the root injector.
import { Routes } from '@angular/router';
import { ReportStore } from './report.store';
export const routes: Routes = [
{
path: 'reports',
providers: [ReportStore],
loadComponent: () => import('./reports.component').then((m) => m.ReportsComponent),
children: [
{ path: ':id', loadComponent: () => import('./report-detail.component').then((m) => m.ReportDetailComponent) },
],
},
];
Both ReportsComponent and its child ReportDetailComponent inject the same ReportStore, but the store ceases to exist once the user leaves /reports, so cached report data is discarded automatically.
Verifying the scope
A quick way to prove instances are (or are not) shared is to log a unique id in the constructor:
@Injectable()
export class PanelState {
readonly id = Math.random().toString(36).slice(2, 7);
constructor() { console.log('PanelState created:', this.id); }
}
Rendering two <app-panel> elements prints:
Output:
PanelState created: a3f9k
PanelState created: q7m2p
Two distinct ids confirm two separate instances. Switch the registration to providedIn: 'root' and you would see the constructor log exactly once for the whole application.
Best practices
- Prefer
providedIn: 'root'by default — it is tree-shakable and avoids accidental duplicate instances. - Reach for component
providersonly when each component instance genuinely needs isolated state. - Use route-level
providersto scope feature state to a lazy route so it is created and destroyed with the feature. - Drop
providedInfrom services that are meant to be scoped, so they cannot leak into the root injector by mistake. - Use
viewProvidersinstead ofproviderswhen projected content should not see the service. - Avoid registering the same service in both
rootand a component — the closer provider silently wins and creates a second instance.