Angular Dependency Injection Explained: inject(), Providers, and the Root Injector [2026]
Every Angular service you have ever heard of — HttpClient, Router, FormBuilder, your own AuthService — arrives in your component the same way: you ask the injector for it, and the injector hands it to you. That request-and-receive dance is dependency injection, the most opinionated and most productive idea in the framework.
This is lesson 1.3 of the Angular Tutorial. Lesson 1.6 showed the provider side — how things get registered into the injector via appConfig. This lesson is the consumer side: how a component, service, or function asks for a value the injector knows about, and what happens behind that one-line inject() call.
The mental model #
DI sounds abstract until you have it framed correctly. The whole pattern is a vending machine:
| Step | Vending machine | Angular DI |
|---|---|---|
| You declare what you want | Press B7 | inject(HttpClient) |
| Something else decides where it comes from | The vending machine's stock | The injector's providers |
| You receive an instance, not a recipe | A bag of chips | An HttpClient instance |
The key trick: the code that uses the value never decides where the value comes from. Your component does not import the HttpClient class and call new HttpClient(). It asks for one, and an injector somewhere — root, route, component — supplies it. Swap the provider in appConfig and every consumer sees the new instance automatically. That is the productivity payoff.
Asking for a dependency: the inject() function #
For every Angular API older than v15, you got dependencies via constructor parameters. For everything in v15+, the idiomatic way is the inject() function:
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-user-list',
template: `<p>{{ count() }} users loaded.</p>`,
})
export class UserListComponent {
private http = inject(HttpClient);
// ... use this.http in methods
}
Three facts about inject():
- It returns a fully-constructed instance. No
new, noawait, no manual wiring. - It only works in an injection context — class-field initializers, constructors,
@Injectablefactories, route guards/resolvers, routeprovidersfactories, and insiderunInInjectionContext(). We will return to this. - It is type-safe.
inject(HttpClient)is typed asHttpClient. The token you pass tells TypeScript what you get back.
The legacy way still works — constructor parameters with types:
// Legacy but valid
export class UserListComponent {
constructor(private http: HttpClient) {}
}
New code prefers inject() because it works in more contexts (route resolvers, functional guards, top-level service factories) and reads cleaner when you have five dependencies. The mental cost is identical.
Injection contexts: where you can call inject() #
Angular wires up an injection context only at specific moments:
| Context | When you can call inject() |
|---|---|
| Component / Directive / Pipe class | In field initializers and the constructor |
@Injectable service |
Same — field initializers and the constructor |
| Route guard / resolver functions | In the function body (Angular wraps the call) |
provideRouter factory |
Inside the factory function |
Custom useFactory |
Inside the factory function |
| Anywhere else | Wrap your code in runInInjectionContext(injector, () => { ... }) |
The biggest gotcha here: inject() does not work inside a setTimeout callback, a subscribe() callback, or anywhere outside the constructor / field initializer flow unless you capture the injector at construction time and pass it back with runInInjectionContext. If you forget this, you get NG0203: inject() must be called from an injection context.
Where the value comes from: the injector #
The value an inject() call returns is decided by the nearest injector that has a provider for the requested token. There are five tiers, in lookup order:
- Element injector — the component or directive you are inside
- Element injector chain — walk up through parent components/directives
- Route injector — providers declared on the matched
Routeconfig - Lazy-loaded module injector — providers from a
loadChildrenboundary - Root injector — providers from
appConfig
Angular walks from the most-local out to root. The first injector that has a provider wins; the call returns its instance. If no tier has one, you get a NullInjectorError.
In 90% of cases, every service is registered at the root and the lookup ends there. The other 10% — where the same token resolves to different instances depending on context — is what makes DI more than a glorified service locator. We will hit those cases in lessons 5.5 (route providers) and 5.6 (lazy loading), and in depth in lesson 1.7.
Registering a service: the @Injectable({ providedIn: 'root' }) shorthand #
For 99% of services, you do not touch appConfig at all. You annotate the class:
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class AuthService {
isLoggedIn = signal(false);
login(/* ... */) { /* ... */ }
}
That one line tells Angular: "register this class with the root injector, and tree-shake it out of the bundle if nothing injects it." You never edit appConfig to add AuthService — the framework picks it up automatically.
Three other providedIn modes exist:
providedIn: 'platform'— a singleton across multiple Angular apps in the same page (rare, useful for micro-frontends)providedIn: 'any'— a new instance per lazy-loaded module (use case: per-feature scoped state)providedIn: <Component>— scope the service to a specific component subtree (most common alternative to'root')
For day one, always use providedIn: 'root'. Reach for the others only when you have a real reason — and lesson 1.7 walks through what those reasons look like.
Component-scoped providers #
Sometimes you want every instance of <feature-page> to have its own copy of FeatureStateService. The component's providers array does that:
@Component({
selector: 'feature-page',
providers: [FeatureStateService], // ← new instance per <feature-page>
template: `...`,
})
export class FeaturePageComponent {
private state = inject(FeatureStateService);
}
Now two <feature-page> elements on the same screen each see their own FeatureStateService. Child components that inject(FeatureStateService) get the parent's instance — that is the hierarchical injector at work.
This is the lever you reach for when you want isolated state per route, per modal, or per repeated component. Lesson 1.7 covers the resolution rules (@Self, @SkipSelf, @Host) that let you override the walk-up behavior.
A realistic example end-to-end #
A tiny service + component pair that uses everything from this lesson:
// auth.service.ts
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class AuthService {
user = signal<{ name: string } | null>(null);
async login(name: string) {
// Pretend we hit an API
this.user.set({ name });
}
logout() {
this.user.set(null);
}
}
// header.component.ts
import { Component, inject } from '@angular/core';
import { AuthService } from './auth.service';
@Component({
selector: 'app-header',
template: `
@if (auth.user(); as user) {
<span>Hi, {{ user.name }}</span>
<button (click)="auth.logout()">Sign out</button>
} @else {
<button (click)="auth.login('Pradeep')">Sign in</button>
}
`,
})
export class HeaderComponent {
protected auth = inject(AuthService);
}
Notice what is not here:
- No
@NgModuleregistration ofAuthService - No factory function, no
providers: [...] - No import of
AuthServiceinappConfig.ts - No
BrowserModule, noforRoot()
AuthService lives at the root because it said providedIn: 'root'. The header component asks for one via inject(). The framework hands one over. The state lives on the service as a signal, and the template reads it directly. That is the entire wiring story for the most common shape of Angular code.
Common gotchas #
| Symptom | Cause | Fix |
|---|---|---|
NG0203: inject() must be called from an injection context |
You called inject() inside a setTimeout, a subscribe, or another async callback |
Move the inject() to a class field, or capture an Injector at construction time and use runInInjectionContext(injector, () => inject(MyService)) |
NullInjectorError: No provider for HttpClient! |
You inject HttpClient but did not register provideHttpClient() in appConfig |
Add provideHttpClient() to the providers array |
| Two components share state when they shouldn't | The service is providedIn: 'root' (one global instance) |
Move the provider to the component's providers: [...] array — one instance per component |
| Two components don't share state when they should | The service is provided on each component | Move the provider to the parent component, or use providedIn: 'root' |
| Service constructor runs twice | Both the root injector AND a component injector list the same service | Remove the duplicate registration. A service should be registered in exactly one place |
inject() returns null when the token is missing |
You used inject(TOKEN, { optional: true }) |
Either provide the token or check for null before use |
What's next #
Lesson 1.7 is the deep dive: InjectionToken<T>, the @Optional/@Self/@SkipSelf/@Host decorators, multi-providers, useFactory factories, and the full resolution algorithm. It is the lesson you come back to when a real codebase makes you ask "wait, why is the injector returning that instance?". Read it after you have written a few services with the simple providedIn: 'root' pattern from above.
Lesson 1.4 takes a different route — the TypeScript essentials an Angular developer touches every day: decorators, generics, type narrowing in templates, the strict flags worth knowing.
Try it yourself #
The sharpest way to feel the DI mechanic is to register a service, inject it in two different components, and confirm they see the same instance:
@Injectable({ providedIn: 'root' })
export class CounterService {
count = signal(0);
increment() { this.count.update(n => n + 1); }
}
@Component({ selector: 'app-a', template: `<button (click)="c.increment()">A: {{c.count()}}</button>` })
export class AComponent { c = inject(CounterService); }
@Component({ selector: 'app-b', template: `<button (click)="c.increment()">B: {{c.count()}}</button>` })
export class BComponent { c = inject(CounterService); }
Click the button in <app-a> and <app-b> updates too — same service instance, same signal. Now move providers: [CounterService] onto each component and click again. They diverge. That is hierarchical DI in two minutes.
get_best_practicesprovidedIn: 'root' is the right default. The cases to deviate:• Per-route state — register on the
Route.providers array so each route activation gets a fresh instance• Modal/dialog state — register on the modal component so it dies with the modal
• Lazy-loaded feature state —
providedIn: 'any' gives each lazy boundary its own instance• Per-component-instance state — register in the component’s
providers arrayReach for these only when you have a real isolation requirement. Default to root.
Lesson 1.7 picks up the harder edges of the DI system. Lesson 1.4 picks up the TypeScript surface an Angular dev hits every day.
Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


