Scoping services correctly avoids shared-state bugs, enables per-feature configuration and powers library design.
Angular has a tree of injectors, and inject(Token) walks it from the requesting point upward until a provider is found.
- Element injectors come from
providers(andviewProviders) in@Component/@Directive. Each component instance gets its own instance of those services, shared with its descendants. Use it for state scoped to a widget, e.g. oneFormStateper wizard. - Environment injectors come from
bootstrapApplicationproviders (root),@Service()/providedIn: 'root', and route-levelproviders, which create a child environment injector for that route subtree (useful for feature-scoped configuration).
Resolution checks the element injector chain first (from the requesting element up to the root component), then the environment injector chain. If nothing is found, it throws NullInjectorError.
Provider forms: { provide: T, useClass: Impl }, useValue, useFactory: () => ... (factories can call inject), and useExisting (alias). `InjectionToken<T>` provides non-class values (config objects, functions) with a type; it can carry its own factory to be tree-shakable. `multi: true` collects several providers into an array, a plugin pattern used by things like HTTP_INTERCEPTORS and form validators.
Resolution modifiers via inject(T, { optional, self, skipSelf, host }) control the search: optional returns null instead of throwing; self looks only at the current element; skipSelf starts at the parent (useful for nested tree components); host stops at the host component.
Example
import { Component, Injectable, InjectionToken, inject, signal } from '@angular/core';
export interface AppConfig { apiUrl: string; retries: number }
export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG', {
providedIn: 'root',
factory: () => ({ apiUrl: '/api', retries: 2 }), // default, tree-shakable
});
@Injectable() // not auto-provided
export class Counter { value = signal(0); }
@Component({
selector: 'app-counter-panel',
providers: [Counter], // new instance per panel
template: `<button (click)="c.value.set(c.value() + 1)">{{ c.value() }}</button>`,
})
export class CounterPanel { protected c = inject(Counter); }
@Component({ selector: 'app-tree-node', template: `depth {{ depth }}<ng-content />` })
export class TreeNode {
private parent = inject(TreeNode, { optional: true, skipSelf: true }); // nearest ancestor
protected depth: number = this.parent ? this.parent.depth + 1 : 0;
}
// Route-scoped override:
// { path: 'admin', providers: [{ provide: APP_CONFIG, useValue: { apiUrl: '/admin-api', retries: 0 } }], ... }Remember
- Element injectors (component providers) scope instances per component subtree
- Environment injectors: root, application and route-level providers
- useClass/useValue/useFactory/useExisting, InjectionToken and multi providers
inject(T, { optional, self, skipSelf, host })controls the lookup
Watch out for
- Providing a stateful service in a component's providers by accident, creating separate instances where a singleton was expected.