~6 min de lecture
providers vs viewProviders : le service qui disparaît quand tu projettes ton contenu
Ton composant Form fournit un Validation. Ton composant Field l'injecte pour lire les erreurs. Tu écris ton démo :
<app-form>
<app-field name="email" />
</app-form>
Runtime : NG0201: No provider for _Validation found in _Field.
Sauf que si tu déclares le Field dans le template du Form, ça marche. Deux mêmes composants, deux mêmes injecteurs en apparence, deux comportements opposés. Bienvenue dans la différence entre providers et viewProviders, deux tableaux qui se ressemblent et qui te donnent deux visions radicalement différentes de ta DI hiérarchique.
Cet article part d'un cas reproductible, explique la règle exacte de résolution en Angular 17+, et te donne 4 patterns pour ne plus jamais laisser un NG0201 t'échapper.
TL;DR
| Question | providers |
viewProviders |
|---|---|---|
| Enfants dans le template du composant | Voit le service | Voit le service |
Enfants projetés via <ng-content> |
Voit le service | Ne voit pas le service |
| Injectable depuis un parent extérieur | Non | Non |
| Cas d'usage typique | Form + Field, Tabs + Tab, Accordion + Panel |
Design system qui doit isoler son état interne du consumer |
| Erreur classique en cas de mauvais choix | Deux instances du même service coexistent | NG0201 sur du contenu projeté |
Règle mnémotechnique : providers est hérité partout, viewProviders s'arrête à la frontière du template du composant qui le déclare.
Démonstration : le bug en 40 lignes
On modélise un Form qui distribue un service de validation à ses champs.
// validation.ts
import { Injectable, signal } from '@angular/core';
@Injectable()
export class Validation {
private readonly errorMap = signal<Record<string, string>>({});
errorFor(name: string): string | null {
return this.errorMap()[name] ?? null;
}
setError(name: string, message: string): void {
this.errorMap.update((map) => ({ ...map, [name]: message }));
}
}
// field.ts
import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core';
import { Validation } from './validation';
@Component({
selector: 'app-field',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<label>
<span>{{ name() }}</span>
<input [name]="name()" />
@if (error(); as message) {
<em class="error">{{ message }}</em>
}
</label>
`,
})
export class Field {
readonly name = input.required<string>();
private readonly validation = inject(Validation);
protected error() {
return this.validation.errorFor(this.name());
}
}
// form.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Validation } from './validation';
@Component({
selector: 'app-form',
changeDetection: ChangeDetectionStrategy.OnPush,
viewProviders: [Validation], // <-- le piège est ici
template: `
<form>
<ng-content />
</form>
`,
})
export class Form {}
<!-- shell.html -->
<app-form>
<app-field name="email" />
</app-form>
Runtime : NG0201: No provider for _Validation found in _Field.
Change une seule chose : viewProviders → providers. Ça marche.
Pourquoi ? Parce que <app-field> est déclaré dans le template du shell, pas dans le template du Form. Il traverse <ng-content> pour se retrouver au bon endroit dans le DOM, mais son injecteur reste ancré à l'endroit où il a été déclaré, pas où il est rendu.
viewProviders ne rend un service visible qu'aux enfants déclarés à l'intérieur du template du composant. Un enfant projeté est un enfant du consumer, pas du composant qui projette.
La règle exacte de résolution
Angular maintient deux graphes de résolution pour chaque composant :
- Injecteur d'élément (element injector) : construit à partir du parent DOM logique du composant, avec les
providersaccumulés le long du chemin. - Injecteur de vue (view injector) : construit à partir du composant qui héberge le template dans lequel l'élément a été déclaré, avec les
viewProvidersde ce composant.
Quand un composant demande un service via inject(), Angular remonte :
- d'abord dans son element injector (parents DOM logiques),
- puis dans le view injector du composant hôte,
- puis dans les injecteurs racines (
root,platform,null).
Résumé opérationnel :
- Un enfant déclaré dans le template de
Formbénéficie à la fois de son element injector (qui contientForm) et de son view injector (qui contientForm). - Un enfant projeté via
<ng-content>conserve l'element injector du consumer (shell). Le view injector duFormne fait pas partie de sa chaîne.
C'est exactement la raison pour laquelle viewProviders est invisible au contenu projeté : le view injector n'est jamais consulté depuis l'extérieur.
Cette règle est stable en Angular 17+, et elle ne change pas avec les signal queries ou la migration standalone. Elle ne dépend pas non plus du mode zoneless.
Pattern 1 : providers pour un composant compound (Form, Tabs, Accordion)
Dès que ton composant est prévu pour être utilisé avec du contenu projeté qui a besoin d'un état partagé, tu es sur providers. C'est le pattern compound de React porté à Angular.
// tabs.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
@Component({
selector: 'app-tabs',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [Tabs], // le service EST le composant
template: `<ng-content />`,
})
export class Tabs {
private readonly activeId = signal<string | null>(null);
isActive(id: string): boolean {
return this.activeId() === id;
}
activate(id: string): void {
this.activeId.set(id);
}
}
// tab.ts
import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core';
import { Tabs } from './tabs';
@Component({
selector: 'app-tab',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (parent.isActive(id())) {
<ng-content />
}
`,
})
export class Tab {
readonly id = input.required<string>();
protected readonly parent = inject(Tabs);
}
Consommation :
<app-tabs>
<app-tab id="overview">Overview</app-tab>
<app-tab id="settings">Settings</app-tab>
</app-tabs>
Ici, Tabs doit être en providers. Un viewProviders casserait immédiatement l'API publique du composant.
Pattern 2 : viewProviders pour un composant qui doit isoler son état
Prends un composant de design system qui utilise en interne un service de layout, de theming ou de gestion de focus. Tu ne veux pas que ce service soit accessible depuis l'extérieur, sinon le premier consumer qui inject() ton service interne verrouille ton API et transforme un détail d'implémentation en contrat public.
// modal.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ModalScrollLock } from './modal-scroll-lock';
import { ModalCloseButton } from './modal-close-button';
@Component({
selector: 'app-modal',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ModalCloseButton],
viewProviders: [ModalScrollLock], // <-- détail d'implémentation
template: `
<div class="backdrop">
<div class="dialog">
<app-modal-close-button />
<ng-content />
</div>
</div>
`,
})
export class Modal {}
ModalCloseButton est déclaré dans le template du Modal. Il voit ModalScrollLock. Le contenu projeté par le consumer, lui, ne le voit pas. Impossible pour un inject(ModalScrollLock) de l'extérieur de se rattacher au service interne du Modal.
C'est exactement l'encapsulation que tu veux pour un composant de design system : viewProviders te donne un service scoppé, invisible du monde extérieur, non contournable.
Pattern 3 : le piège des deux instances
Le cas inverse est encore plus vicieux. Tu déclares un Validation dans les providers d'un Form. Tu déclares un autre Validation dans les providers d'un Field (par oubli, par copier-coller, ou par « je vais mettre ce service par défaut pour ne pas planter »). Résultat : chaque Field a sa propre instance de Validation, indépendante de celle du Form. Le formulaire ne voit jamais les erreurs, aucune exception ne remonte.
// field.ts
@Component({
selector: 'app-field',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [Validation], // <-- crée une deuxième instance
template: `...`,
})
export class Field {
private readonly validation = inject(Validation); // <-- pas celle du Form
}
C'est un bug silencieux : la DI trouve un provider dans l'element injector local, s'arrête, et ne remonte jamais jusqu'au Form. Le test unitaire du Field passe parce qu'il crée son propre Validation. Le test du Form passe parce qu'il n'a pas de vrais Field projetés. Et en prod, la validation ne remonte jamais.
Règle : un service partagé entre parent et enfant se déclare au seul niveau parent. Point. La Validation du Field ne s'écrit jamais.
Pattern 4 : providedIn: 'root' n'est pas la solution universelle
Tentation classique quand tu tombes sur un NG0201 : passer le service en providedIn: 'root'. Ça règle l'erreur, oui. Ça casse la sémantique, absolument.
Si Validation est en root, alors tous les Form de l'app partagent la même instance. Ouvrir un formulaire de création à côté d'un formulaire d'édition met les erreurs de l'un dans l'autre. Le service perd sa raison d'être : le scope par formulaire.
providedIn: 'root' est le bon choix pour un service stateless ou pour un état global partagé volontairement. Pour tout ce qui doit être scoppé à une instance de composant, providers sur le composant parent est la seule option correcte.
Récap actionnable
providers: le service est visible par tout enfant, projeté ou pas. C'est ce que tu veux pour un composant compound (Form+Field,Tabs+Tab,Accordion+Panel,Radio+RadioOption).viewProviders: le service est visible uniquement par les enfants du template du composant. C'est ce que tu veux pour un composant qui doit encapsuler un détail d'implémentation et interdire l'accès depuis l'extérieur.- Ne redéclare jamais un service partagé au niveau enfant. Tu casses la remontée d'injecteur et tu crées deux instances silencieusement.
- Ne passe pas un service en
providedIn: 'root'par facilité pour contourner unNG0201sur du contenu projeté. Le vrai fix est de passer deviewProvidersàprovidersau bon niveau. - Écris un test qui projette vraiment un enfant dans le template du parent, pas un test qui déclare l'enfant à côté. C'est le seul moyen de détecter un
viewProvidersmal placé avant la prod.
Angular 17, 18, 19, 20, 21, 22 : cette règle n'a pas bougé et ne bougera pas. C'est un des rares invariants de la DI hiérarchique. Une fois que tu tiens la distinction template vs projection dans la tête, tu ne te fais plus jamais avoir.