📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~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 : viewProvidersproviders. Ç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 :

  1. Injecteur d'élément (element injector) : construit à partir du parent DOM logique du composant, avec les providers accumulés le long du chemin.
  2. 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 viewProviders de 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 Form bénéficie à la fois de son element injector (qui contient Form) et de son view injector (qui contient Form).
  • Un enfant projeté via <ng-content> conserve l'element injector du consumer (shell). Le view injector du Form ne 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 un NG0201 sur du contenu projeté. Le vrai fix est de passer de viewProviders à providers au 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 viewProviders mal 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.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

ng-content : 6 patterns de projection Angular qui séparent le composant réutilisable du jetable

6 juillet 2026

Tu poses un <ng-content> dans ton composant Card et tu penses que c'est réutilisable. Sauf que le slot vide casse ton layout, l'ordre de tes select te joue des tours, un <ng-container> disparait dans un slot à sélecteur de tag, et détecter un slot vide te force à toucher au DOM. 6 patterns concrets, tous vérifiés en test, avec du signal-based contentChild pour boucler proprement en Angular 20+.

Angular Content Projection ng-content Composants Signals

Angular 19+ : `provideAppInitializer` enterre `APP_INITIALIZER` (et te fait gagner 70% de boilerplate)

10 juin 2026

Angular 19 a introduit provideAppInitializer et marqué le vieux token APP_INITIALIZER deprecated. Plus de useFactory, plus de deps array typé any, plus de double lambda : un callback qui tourne en contexte d'injection, avec inject() qui marche directement. Migration complète en 4 minutes, avec les pièges qui restent.

Angular APP_INITIALIZER provideAppInitializer Bootstrap Dependency Injection

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.