Angular Hierarchical Dependency Injection: Providers, @Self, @SkipSelf, @Optional, @Host

Angular Tutorial: Beginner to Advanced · 7. Services and dependency injection · 7 min read · Updated

In the injection tokens lesson you learned how to provide values. This lesson explains where Angular looks for them. Once you understand the lookup path, every dependency injection puzzle becomes simple.

What you will learn

  • The injector tree and how a lookup travels up it
  • Singleton services, and providedIn options
  • self, skipSelf, optional and host, as functions and as decorators
  • viewProviders, @Inject and the Injector class

The injector tree

Angular creates injectors in a tree that mirrors your app:

  1. Platform injector (top): shared by every Angular app on the page.
  2. Root injector: your app's singletons (providedIn: 'root', and app.config.ts).
  3. Route injectors: for routes with their own providers, and lazy routes.
  4. Element injectors: one per component or directive, from the component's providers.

When a component asks for a service, Angular starts at its own element injector and walks up until it finds a provider. The first match wins. If nobody provides it, you get an error, unless you made it optional.

Singleton services

@Injectable({ providedIn: 'root' }) registers the service in the root injector, so there is one instance for the whole app. Everyone who injects it gets the same object, which makes it the right place for shared state. Because the service is only included in the bundle when it is used, it is also tree-shakable.

Other values for providedIn:

  • 'platform': one instance shared by several Angular apps on the same page. Rare.
  • 'any': a separate instance per lazy-loaded injector. It is deprecated, so prefer 'root' or route-level providers.
  • Not set: you must list the service in some providers array.

One service, many instances

// src/app/di2/counter.service.ts
import { Injectable, signal } from '@angular/core';

@Injectable()
export class CounterService {
  readonly count = signal(0);

  increment(): void {
    this.count.update((n) => n + 1);
  }
}

There is no providedIn, so it is not global. Each component that lists it in providers gets its own instance:

// src/app/di2/counter-panel.ts
import { Component, inject } from '@angular/core';
import { CounterService } from './counter.service';

@Component({
  selector: 'app-counter-panel',
  providers: [CounterService], // a new instance per panel
  template: `
    <p>{{ label }}: {{ counter.count() }}</p>
    <button type="button" (click)="counter.increment()">+1</button>
    <ng-content />
  `,
})
export class CounterPanel {
  protected readonly counter = inject(CounterService);
  protected label = 'Panel';
}
// src/app/di2/counter-button.ts
import { Component, inject } from '@angular/core';
import { CounterService } from './counter.service';

// Has no providers of its own. It finds the CounterService of the nearest parent panel.
@Component({
  selector: 'app-counter-button',
  template: `<button type="button" (click)="counter.increment()">Child +1</button>`,
})
export class CounterButton {
  protected readonly counter = inject(CounterService);
}

Use it twice: each <app-counter-panel> has its own count, and the CounterButton inside a panel increments that panel's count, because the lookup walks up to the nearest provider.

// src/app/di2/di-page.ts
import { Component } from '@angular/core';
import { CounterButton } from './counter-button';
import { CounterPanel } from './counter-panel';

@Component({
  selector: 'app-di-page',
  imports: [CounterPanel, CounterButton],
  template: `
    <app-counter-panel><app-counter-button /></app-counter-panel>
    <app-counter-panel><app-counter-button /></app-counter-panel>
  `,
})
export class DiPage {}

Resolution modifiers

You can change how the lookup behaves with options on inject():

  • `optional: true`: Meaning: Return null instead of throwing if not found; Old decorator: @Optional()
  • `self: true`: Meaning: Look only in this element's own injector; Old decorator: @Self()
  • `skipSelf: true`: Meaning: Skip this element, start at the parent; Old decorator: @SkipSelf()
  • `host: true`: Meaning: Stop at the host element of the current component; Old decorator: @Host()
// src/app/di2/tree-node.ts
import { Component, inject } from '@angular/core';

// A node that knows its parent node, if it has one.
@Component({
  selector: 'app-tree-node',
  providers: [],
  template: `
    <p>{{ name }} (parent: {{ parent ? parent.name : 'none' }})</p>
    <ng-content />
  `,
})
export class TreeNode {
  protected name = 'node';

  // skipSelf: do not find ourselves. optional: the root node has no parent.
  protected readonly parent = inject(TreeNode, { skipSelf: true, optional: true });
}

The old decorator style does the same thing in constructor parameters:

// src/app/di2/legacy-di.ts
import { Component, Host, Inject, Injectable, InjectionToken, Optional, Self, SkipSelf } from '@angular/core';

export const GREETING = new InjectionToken<string>('GREETING');

@Injectable({ providedIn: 'root' })
export class Logger {
  log(message: string): void {
    console.log(message);
  }
}

@Component({
  selector: 'app-legacy-di',
  providers: [{ provide: GREETING, useValue: 'hello from the component' }],
  template: `<p>{{ greeting }}</p>`,
})
export class LegacyDi {
  constructor(
    @Inject(GREETING) protected greeting: string, // @Inject chooses the token
    @Optional() @Self() private own: Logger | null, // looked up only on this element, may be missing
    @SkipSelf() @Optional() private parent: LegacyDi | null,
    @Host() @Optional() private host: LegacyDi | null,
  ) {}
}

Rules: @Optional() changes a missing provider from an error to null. @Self() limits the search to the component's own injector (so the root service is not found, and own is null). @SkipSelf() skips the component's injector, and @Host() stops at the component's host boundary.

Where would you actually use these? Mostly inside reusable library components: a tab finding its tabs parent, a menu item finding its menu, a form field finding the enclosing form. For app code, prefer plain inject(Service).

viewProviders

providers on a component are visible to the component's template and to content projected into it. viewProviders are visible only to the component's own view, and hidden from projected content:

// src/app/di2/secret-box.ts
import { Component, InjectionToken, inject } from '@angular/core';

export const SECRET = new InjectionToken<string>('SECRET');

@Component({
  selector: 'app-secret-reader',
  template: `<p>Secret: {{ value ?? 'not visible here' }}</p>`,
})
export class SecretReader {
  protected readonly value = inject(SECRET, { optional: true });
}

@Component({
  selector: 'app-secret-box',
  imports: [SecretReader],
  viewProviders: [{ provide: SECRET, useValue: 'only the view sees me' }],
  template: `
    <app-secret-reader />   <!-- inside the view: finds the secret -->
    <ng-content />          <!-- projected content: does not -->
  `,
})
export class SecretBox {}

If a projected <app-secret-reader /> is placed between the <app-secret-box> tags, it gets null. Use viewProviders to keep a service private to the component's own template.

The Injector class

Sometimes you need to get a dependency later, or from code that is not a constructor:

// src/app/di2/lookup.ts
import { Injectable, Injector, inject } from '@angular/core';
import { Logger } from './legacy-di';

@Injectable({ providedIn: 'root' })
export class Lookup {
  private readonly injector = inject(Injector);

  logLater(): void {
    setTimeout(() => this.injector.get(Logger).log('late lookup'), 100);
  }
}

injector.get(Token) asks the injector directly. This also breaks circular dependencies, because the lookup is delayed. In functions that run outside an injection context, use runInInjectionContext(injector, () => inject(X)).

Test the rules

// src/app/di2/di.spec.ts
import { TestBed } from '@angular/core/testing';
import { CounterButton } from './counter-button';
import { DiPage } from './di-page';
import { SecretBox, SecretReader } from './secret-box';
import { Component } from '@angular/core';

describe('hierarchical DI', () => {
  it('gives each panel its own service instance', async () => {
    const fixture = TestBed.createComponent(DiPage);
    await fixture.whenStable();
    const el = fixture.nativeElement as HTMLElement;

    el.querySelectorAll<HTMLButtonElement>('app-counter-button button')[0].click();
    await fixture.whenStable();

    const texts = Array.from(el.querySelectorAll('app-counter-panel > p')).map((p) => p.textContent?.trim());
    expect(texts).toEqual(['Panel: 1', 'Panel: 0']);
  });

  it('hides viewProviders from projected content', async () => {
    @Component({
      imports: [SecretBox, SecretReader],
      template: `<app-secret-box><app-secret-reader /></app-secret-box>`,
    })
    class Host {}

    const fixture = TestBed.createComponent(Host);
    await fixture.whenStable();
    const readers = Array.from((fixture.nativeElement as HTMLElement).querySelectorAll('app-secret-reader')).map(
      (r) => r.textContent?.trim(),
    );

    expect(readers).toEqual(['Secret: only the view sees me', 'Secret: not visible here']);
  });
});

Both tests pass, which confirms the rules above: the nearest provider wins, and viewProviders stay inside the view.

Quick map: which provider where?

  • Shared app-wide state: Where to provide: providedIn: 'root'
  • Per-feature state: Where to provide: Route providers
  • Per-component state: Where to provide: Component providers
  • Hide from projected content: Where to provide: viewProviders
  • Configuration values: Where to provide: InjectionToken in app.config.ts

Try it yourself

  1. Make the TreeNode show its depth by walking up the parent chain.
  2. Change CounterService to providedIn: 'root' and see how both panels now share one count.
  3. Use inject(Logger, { self: true, optional: true }) and explain why you get null.

Quick check

  1. In which order does Angular search injectors?
  2. What does skipSelf do?
  3. How do viewProviders differ from providers?

Next, continue with Angular Reactive Forms for Beginners.

Frequently asked questions

Is a service a singleton by default?

Only if it is provided in the root injector (for example providedIn root). A service listed in a component's providers is created again for each component instance.

Are @Inject and @Optional deprecated?

No, but inject() with options does the same thing and works outside constructors, so most new code uses it.