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
providedInoptions self,skipSelf,optionalandhost, as functions and as decoratorsviewProviders,@Injectand theInjectorclass
The injector tree
Angular creates injectors in a tree that mirrors your app:
- Platform injector (top): shared by every Angular app on the page.
- Root injector: your app's singletons (
providedIn: 'root', andapp.config.ts). - Route injectors: for routes with their own
providers, and lazy routes. - 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-levelproviders.- Not set: you must list the service in some
providersarray.
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
nullinstead 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:
InjectionTokeninapp.config.ts
Try it yourself
- Make the
TreeNodeshow its depth by walking up theparentchain. - Change
CounterServicetoprovidedIn: 'root'and see how both panels now share one count. - Use
inject(Logger, { self: true, optional: true })and explain why you getnull.
Quick check
- In which order does Angular search injectors?
- What does
skipSelfdo? - How do
viewProvidersdiffer fromproviders?
Next, continue with Angular Reactive Forms for Beginners.