Where you put a provider decides how many instances exist and how long they live. Angular has a hierarchy of injectors: when you ask for something with inject(), the search starts at the current element and climbs until it finds a provider. This lets you have a global singleton, one instance per feature, or one instance per component — with the same service code.
The hierarchy
┌──────────────────────────────┐
│ Platform injector │ (rare: providedIn: 'platform')
└──────────────┬───────────────┘
┌──────────────▼───────────────┐
│ Root (EnvironmentInjector) │ @Service(), providedIn: 'root', app.config.ts
└──────────────┬───────────────┘
┌──────────────▼───────────────┐
│ Route injectors │ { path, providers: [...] } — for lazy routes
└──────────────┬───────────────┘
┌──────────────▼───────────────┐
│ Element injectors │ @Component({ providers, viewProviders })
│ (follows the DOM tree) │
└──────────────────────────────┘
inject(X) looks for: the current element → its DOM parents → the route injector → root → platform → error NG0201The three levels you use every day
// 1. ROOT — one instance for the whole application
@Service()
export class AuthStore {}
// 2. ROUTE — one instance for a feature (everything under /admin)
export const routes: Routes = [
{
path: 'admin',
providers: [AdminStore, { provide: API_URL, useValue: '/api/admin' }],
loadChildren: () => import('./admin/admin.routes'),
},
];
// 3. COMPONENT — one instance per component (and its children)
@Component({
selector: 'app-checkout',
providers: [CheckoutState], // each <app-checkout> has its own state
})
export class Checkout {}#130#130Two children, one provider → they share the instance.
#140Another provider → another instance, separate state.
#15#16nullself, skipSelf, optional.
| Where | Instances | Lifetime | Examples |
|---|---|---|---|
| root | 1 | the whole application | auth, theme, cart, cache, HttpClient |
| route | 1 per route (lazy) | from the first activation (+ optional cleanup in v22.2) | feature store, feature config |
| component | 1 per component instance | as long as the component | wizard, form, editor, table state |
Provider recipes
providers: [
// useClass — another implementation for the same token
{ provide: Logger, useClass: environment.production ? RemoteLogger : ConsoleLogger },
// useValue — a fixed value (config, mock)
{ provide: API_URL, useValue: 'https://api.example.com' },
// useFactory — computed, with access to inject()
{ provide: Storage, useFactory: () => (inject(PLATFORM_ID) === 'browser' ? localStorage : new MemoryStorage()) },
// useExisting — an alias: the same object under another token
{ provide: AbstractCart, useExisting: CartStore },
// multi — several providers gathered into an array
{ provide: VALIDATORS, useValue: requiredValidator, multi: true },
{ provide: VALIDATORS, useValue: emailValidator, multi: true },
];Lookup modifiers
inject(Counter, { optional: true }); // null instead of an error if it does not exist
inject(Counter, { self: true }); // only from the current element’s injector
inject(Counter, { skipSelf: true }); // skip the current element, look in the parents
inject(Counter, { host: true }); // stops the search at the host component
// Real example: a directive that can “hook into” an optional parent
export class MenuItem {
private readonly menu = inject(Menu, { optional: true, skipSelf: true });
}@Component({ selector: 'app-menu', template: '…<ng-content />' })
export class Menu {
readonly label = input.required<string>();
private readonly parent = inject(Menu, { skipSelf: true, optional: true });
readonly depth: number = (this.parent?.depth ?? 0) + 1;
}The pattern to use: skipSelf finds the parent, optional turns “there is none” into null for the top menu. The depth follows by itself.
providers vs viewProviders
// providers: visible in the component’s template AND in the projected content (ng-content)
// viewProviders: visible ONLY in its own template, NOT in the projected content
@Component({
selector: 'app-tabs',
viewProviders: [TabsInternalState], // children projected by the parent cannot inject it
template: `<ng-content />`,
})
export class Tabs {}Cleaning up route injectors (v22.2)
// By default, route injectors live as long as the application (once created, they stay).
// v22.2: withAutoCleanupInjectors() destroys them when the route is no longer active
// (with RouteReuseStrategy.shouldDestroyInjector returning true for the routes you want).
provideRouter(routes, withAutoCleanupInjectors());How you choose the level
- The state must survive navigation (user, cart, preferences).
- It is infrastructure without UI state (API client, logger).
- It is used in many features.
- The state must disappear when you leave the page (filters, form, wizard).
- You may need several instances at once (two editors open, two tables).
- Different configuration per area (another
API_URLfor admin).
How to declare a service is covered in inject() & @Service(); values that are not classes (configuration, feature flags) are provided with InjectionToken & provideX().
Common pitfalls
- The same service in
providersat several levels → several instances without meaning to („why isn’t the cart syncing?”). - A root service that injects a component-level service → NG0201: root cannot see element injectors.
providerson components in a large list → one instance per row. Fine if intended, expensive if not.- Route providers are not destroyed by default on navigation — don’t rely on them for a “reset on leave” without
withAutoCleanupInjectors().