Ch. 4 · Angular

Angular providedIn root vs Component Providers vs viewProviders

Where a service is provided decides who shares it: providedIn root, component providers, viewProviders and route providers, with instance ids.

~6 min readbeginnerupdated Oct 7, 2026

Angular’s dependency injection isn’t one big container. It’s a tree of injectors that mirrors your app: a root injector, injectors for lazy routes, and an injector for every component that declares providers. When a class asks for a service, Angular starts at the asking component and walks up until it finds a provider. Where you register a provider therefore decides two things at once: who shares the instance, and how long it lives.

Interview questions on this topic usually come as a scenario. “Two editors on the same page are sharing their unsaved text. Why?” or “Our filter state resets when we navigate away. Where should the service live?” This note answers those with real instance ids from Angular 20.3 running under TestBed in Node. Angular 19 behaves identically.

One instance for everyone: providedIn root

@Injectable({ providedIn: 'root' })
export class Session {
  user = signal<User | null>(null);
}
TypeScript

providedIn: 'root' registers the service with the application’s root injector, lazily: the instance is created the first time something injects it, and if nothing ever does, the bundler can drop it. Every component, directive, service and lazy route in the app gets the same object.

That’s the right default for genuinely app-wide state and stateless helpers: the current user, an API client, a logger. It’s the wrong default for state that belongs to one part of the screen, which is where most “shared state” bugs come from.

One instance per component: the providers array

Consider a page with two rich-text editors. Each needs its own unsaved draft. If Draft were providedIn: 'root', both editors would write to the same object, and typing in one would show up in the other.

Provide it on the component instead:

@Injectable()
export class Draft {
  text = signal('');
}

@Component({
  selector: 'app-editor',
  imports: [Field],
  providers: [Draft],
  template: `<app-field /><ng-content />`,
})
export class Editor {
  draft = inject(Draft);
}
TypeScript

Now every <app-editor> element gets its own injector holding its own Draft. Children inside the editor’s template (<app-field> here) find that instance on their way up the tree. The Session service, still providedIn: 'root', is shared across everything.

With an id stamped on each instance, a page rendering two app-editor elements plus one variant that uses viewProviders (explained below) logs this:

editor draft#1
editor draft#2
field in editor: draft#1 session#1
field in editor: draft#2 session#1
field in editor: draft#3 session#1
Text

Each editor and its own field share a draft. The two editors don’t. All three fields share Session#1. That’s the hierarchy doing exactly what you’d draw on a whiteboard.

The instance is also destroyed with the component. If Draft implements ngOnDestroy, Angular calls it when the editor is removed, which makes component providers a natural home for state that should reset when a dialog closes or a wizard is abandoned.

Note

@Injectable() without providedIn is a deliberate choice here. It means “this service has no default home; whoever wants it must provide it”. If someone injects Draft outside an editor, they get a clear error rather than a silent root instance. In a test with no provider, Angular 20 reported NG0201: No provider found for 'Draft'. The NullInjectorError guide covers that family of errors.

providers versus viewProviders

The editor template has an <ng-content /> slot, and the page projected an <app-probe> into each editor. The probe asks for Draft too, with optional: true. What it found:

projected probe: draft#1      (inside an editor using providers)
projected probe: draft#2      (inside an editor using providers)
projected probe: draft#none   (inside the editor using viewProviders)
Text

Projected content is declared in the parent’s template, but it’s still a child of the editor in the element tree, so it can see the editor’s providers. viewProviders restricts visibility to the component’s own view (its template), and projected content is not part of that view. The probe couldn’t see the draft at all.

Use viewProviders when a component’s service is an implementation detail that consumers who project content shouldn’t depend on. Use providers when you want projected children to cooperate with the host, which is how a tab group shares state with the <app-tab> elements you put inside it. The note on content projection and ownership goes further into that.

Route-level providers and their surprising lifetime

Routes can have providers too:

export const routes: Routes = [
  { path: '', component: Home },
  {
    path: 'reports',
    providers: [Filters],
    children: [
      { path: '', component: Reports },
      { path: ':id', component: ReportDetail },
    ],
  },
];
TypeScript

This creates an environment injector for the reports branch. Here Filters was also providedIn: 'root', so there are two instances: the root one, and the one the route provides. Navigating / then /reports then /reports/7 then back to / and into /reports again logged:

Home -> Filters#1
Reports -> Filters#2
ReportDetail -> Filters#2
Home -> Filters#1
Reports -> Filters#2
Text

The report pages share an instance that’s separate from the root one. And when you come back to /reports, it’s still instance #2. Unlike a component provider, a route’s environment injector is not destroyed when you navigate away. It’s created the first time the route is activated and, by default, lives as long as the app.

That’s useful (filters survive a trip to the home page) and occasionally a surprise (people expect a fresh instance on each visit). If you want per-visit state, provide the service on the route’s component instead, so it’s tied to that component’s lifetime.

Controlling the lookup

inject() accepts options that change how the walk up the tree works:

  • optional: true returns null instead of throwing when nothing is found.
  • self: true looks only in the current element’s injector.
  • skipSelf: true starts at the parent. A nested menu uses this to find its parent menu’s service while also providing its own.
  • host: true stops at the host component’s boundary.

You’ll rarely need more than optional. skipSelf shows up in recursive components, and interviewers sometimes ask about it to see whether you know the lookup is a walk and not a global map.

Interview practice

A product page lists items, and each item has an “edit” panel backed by an EditState service holding the form values. When a user opens two panels, edits in one appear in the other. After they close a panel and reopen it, their old edits are still there even though the product changed. The service is decorated with @Injectable({ providedIn: 'root' }). Explain both bugs and the fix.

Answer

Both symptoms come from the same choice: providedIn: 'root' creates one EditState for the whole application. Every panel injects that single object, so two open panels write to the same fields. And because the root instance lives forever, closing a panel doesn’t throw anything away, so the next panel picks up the stale values.

The state belongs to one panel, so the provider should be on the panel component: remove providedIn (make it @Injectable()) and add providers: [EditState] to EditPanelComponent. Each panel instance gets its own EditState, child components inside the panel share it, and Angular destroys it when the panel is removed, so reopening starts clean. If the panel projects consumer content that shouldn’t touch the state, I’d use viewProviders instead.

I’d also mention what I would not do: keep the root service and add a reset() call on close, or key the state by product id inside a map. Both work, but they rebuild by hand what the injector tree already gives you, and they leave the “two panels at once” case to be handled separately.

Further reading

More in Angular

read ✓Angular · mid

Angular Inject and Injection Contexts

Where inject() works and where it throws NG0203: constructors, field initializers and guards versus ngOnInit, timers and code after await.

~6 min readread →
esc