Hierarchical injectors and providers

Angular · Expert · 16 min

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 (and viewProviders) 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. one FormState per wizard.
  • Environment injectors come from bootstrapApplication providers (root), @Service()/providedIn: 'root', and route-level providers, 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.

Check yourself

  1. Two sibling <app-counter-panel> components each list Counter in their providers. How many Counter instances exist?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: angular.dev/guide/di/hierarchical-dependency-injection