~3 min de lecture
ControlValueAccessor : Crée Tes Propres Inputs Comme Un Pro
Tu veux créer un composant qui fonctionne avec [(ngModel)] et FormControl ? Tu ne sais pas comment faire ?
Dans cet article, je vais t'expliquer POURQUOI tu en as besoin et COMMENT l'implémenter pas à pas.
🤔 POURQUOI : Le Problème Qu'on Veut Résoudre
Le besoin concret
Imagine que tu veux créer un composant de notation (rating) avec des étoiles.
Tu veux pouvoir l'utiliser comme ça :
// Dans un formulaire réactif
const form = this.fb.group({
rating: [3] // Note par défaut
});
<!-- Dans le template -->
<form [formGroup]="form">
<app-rating formControlName="rating"/>
</form>
Ou même comme ça :
<!-- Avec ngModel -->
<app-rating [(ngModel)]="userRating"/>
Le problème
Par défaut, ton composant ne sait PAS communiquer avec Angular Forms.
Si tu essayes formControlName="rating" sur ton composant custom, tu auras une erreur :
ERROR: No value accessor for form control with name: 'rating'
La solution
ControlValueAccessor est l'interface qui permet à ton composant de communiquer avec Angular Forms.
C'est le pont entre ton composant et le système de formulaires d'Angular.
📚 C'est Quoi ControlValueAccessor ?
Définition simple
ControlValueAccessor est une interface qui définit 4 méthodes :
writeValue(): Angular te donne une valeurregisterOnChange(): Tu donnes une fonction à Angular pour lui signaler les changementsregisterOnTouched(): Tu donnes une fonction à Angular pour lui signaler le "touch"setDisabledState(): Angular te dit si le champ est disabled
L'analogie simple
Imagine une boîte aux lettres 📬 :
- writeValue = Quelqu'un met une lettre dans ta boîte (Angular → Toi)
- registerOnChange = Tu donnes ton numéro de téléphone pour qu'on puisse te prévenir quand tu envoies une lettre ( Toi → Angular)
- registerOnTouched = Tu préviens qu'on a ouvert la boîte
- setDisabledState = On te dit que la boîte est fermée
🛠️ COMMENT : Implémentation Pas À Pas
Étape 1 : Créer le composant de base
On va créer un composant de rating avec des étoiles.
@Component({
selector: 'app-rating-field',
template: `
@for (star of stars; track $index) {
<button
type="button"
(click)="setRating($index + 1)"
[class.filled]="$index < value">
★
</button>
}
`,
styles: [`
:host button {
background: none;
border: none;
font-size: 32px;
color: #ddd;
cursor: pointer;
}
.host button.filled {
color: gold;
}
`]
})
export class RatingField {
readonly stars = [1, 2, 3, 4, 5];
value = 0;
setRating(rating: number): void {
this.value = rating;
}
}
Étape 2 : Implémenter ControlValueAccessor
MaintenantOn implémente l'interface :
import { ControlValueAccessor } from '@angular/forms';
@Component({
selector: 'app-rating-field',
template: `...`
})
export class RatingField implements ControlValueAccessor {
readonly stars = [1, 2, 3, 4, 5];
value = 0;
// 🔵 1. Angular nous donne une valeur
writeValue(value: number): void {
this.value = value || 0;
}
// 🔵 2. On garde la fonction pour notifier Angular
private onChange: (value: number) => void = () => {
};
registerOnChange(fn: (value: number) => void): void {
this.onChange = fn;
}
// 🔵 3. On garde la fonction pour notifier le "touch"
private onTouched: () => void = () => {
};
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
// 🔵 4. Angular nous dit si on est disabled
setDisabledState(isDisabled: boolean): void {
// On gère le disabled (optionnel)
}
// Notre logique métier
setRating(rating: number): void {
this.value = rating;
this.onChange(rating); // ✅ On notifie Angular !
this.onTouched(); // ✅ On notifie le touch
}
}
Étape 3 : Enregistrer le provider
On dit à Angular que notre composant est un ControlValueAccessor :
import { NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-rating-field',
// ✅ On enregistre le provider
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => RatingField),
multi: true
}
],
template: `...`
})
export class RatingField implements ControlValueAccessor {
// ...
}
Étape 4 : Utiliser le composant
Maintenant, ça marche ! 🎉
@Component({
selector: 'app-review-form',
imports: [ReactiveFormsModule, RatingField],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<label>Votre note :</label>
<app-rating-field formControlName="rating" />
<p>Note sélectionnée : {{ form.value.rating }}</p>
<button type="submit">Envoyer</button>
</form>
`
})
export class ReviewFormComponent {
private readonly fb = inject(FormBuilder);
readonly form = this.fb.group({
rating: [0] // Valeur par défaut
});
onSubmit(): void {
console.log(this.form.value);
}
}
🔍 Les 4 Méthodes Expliquées Simplement
1️⃣ writeValue() : Reçoit la valeur
Quand ? Angular veut donner une valeur à ton composant.
writeValue(value: number): void {
// Angular dit : "Voilà la valeur : 3"
this.value = value || 0;
}
Exemple :
const form = this.fb.group({
rating: [3] // → writeValue(3) est appelé
});
2️⃣ registerOnChange() : Enregistre le callback
Quand ? Angular te donne une fonction pour que tu puisses lui dire quand la valeur change.
private onChange: (value: number) => void = () => {
};
registerOnChange(fn: (value: number) => void): void {
// Angular dit : "Voici ma fonction, appelle-la quand tu changes"
this.onChange = fn;
}
// Plus tard, quand l'utilisateur clique
setRating(rating: number): void {
this.value = rating;
this.onChange(rating); // ✅ On prévient Angular
}
3️⃣ registerOnTouched() : Enregistre le touch
Quand ? Angular veut savoir quand l'utilisateur a "touché" le champ.
private onTouched: () => void = () => {
};
registerOnTouched(fn: () => void): void {
// Angular dit : "Appelle-moi quand l'utilisateur touche le champ"
this.onTouched = fn;
}
// Quand l'utilisateur clique
setRating(rating: number): void {
this.value = rating;
this.onChange(rating);
this.onTouched(); // ✅ On prévient qu'il a touché
}
C'est utile pour les validations touched :
<app-rating-field formControlName="rating"/>
@if (form.controls.rating.touched && form.controls.rating.invalid) {
<span>Note requise</span>
}
4️⃣ setDisabledState() : Gère le disabled
Quand ? Angular veut activer/désactiver ton composant.
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
Exemple :
this.form.controls.rating.disable(); // → setDisabledState(true)
this.form.controls.rating.enable(); // → setDisabledState(false)
🎯 Exemple Complet : Rating Field Component
Voici l'implémentation complète :
import { Component, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-rating-field',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => RatingComponent),
multi: true
}
],
template: `
@for (star of stars; track $index) {
<button
type="button"
(click)="setRating($index + 1)"
[disabled]="disabled"
[class.filled]="$index < value">
★
</button>
}
`,
styles: [`
:host {
display: flex;
gap: 4px;
}
:host button {
background: none;
border: none;
font-size: 32px;
color: #ddd;
cursor: pointer;
transition: color 0.2s;
}
:host button:hover:not(:disabled) {
color: #ffcc00;
}
:host button.filled {
color: gold;
}
:host button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
`]
})
export class RatingField implements ControlValueAccessor {
readonly stars = [1, 2, 3, 4, 5];
value = 0;
disabled = false;
private onChange: (value: number) => void = () => {
};
private onTouched: () => void = () => {
};
writeValue(value: number): void {
this.value = value || 0;
}
registerOnChange(fn: (value: number) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
setRating(rating: number): void {
if (this.disabled) return;
this.value = rating;
this.onChange(rating);
this.onTouched();
}
}
✨ Bonus : Validation
Ton composant fonctionne automatiquement avec les validators Angular !
const form = this.fb.group({
rating: [0, [Validators.required, Validators.min(1)]]
});
<app-rating-field formControlName="rating"/>
@if (form.controls.rating.touched && form.controls.rating.errors?.['required']) {
<span class="error">La note est requise</span>
}
@if (form.controls.rating.errors?.['min']) {
<span class="error">Note minimum : 1</span>
}
🛠️ Autres Exemples D'Usage
Toggle Switch
@Component({
selector: 'app-toggle-field',
template: `
<button
type="button"
(click)="toggle()"
[class.active]="value">
{{ value ? 'ON' : 'OFF' }}
</button>
`
})
export class ToggleField implements ControlValueAccessor {
value = false;
// ... implémentation CVA
toggle(): void {
this.value = !this.value;
this.onChange(this.value);
this.onTouched();
}
}
Color Picker
@Component({
selector: 'app-color-picker',
template: `
<input
type="color"
[value]="value"
(input)="onColorChange($event)" />
`
})
export class ColorPicker implements ControlValueAccessor {
value = '#000000';
// ... implémentation CVA
onColorChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.value = input.value;
this.onChange(this.value);
this.onTouched();
}
}
❗ Les Erreurs Fréquentes
Erreur 1 : Oublier le provider
// ❌ Sans provider
@Component({
selector: 'app-rating-field'
})
export class RatingField implements ControlValueAccessor {
}
// ✅ Avec provider
@Component({
selector: 'app-rating-field',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => RatingComponent),
multi: true
}
]
})
export class RatingField implements ControlValueAccessor {
}
Erreur 2 : Oublier d'appeler onChange
typscript
// ❌ Oubli
setRating(rating: number):void {
this.value = rating;
// Angular n'est pas notifié !
}
// ✅ Correct
setRating(rating: number): void {
this.value = rating;
this.onChange(rating); // ✅
this.onTouched();
}
Erreur 3 : Modifier directement dans writeValue
// ❌ Mauvais : appeler onChange dans writeValue
writeValue(value: number): void {
this.value = value;
this.onChange(value); // ❌ Boucle infinie !
}
// ✅ Bon : juste mettre à jour la valeur
writeValue(value: number): void {
this.value = value || 0;
}
🎓 Conclusion : Tu Maîtrises ControlValueAccessor
Maintenant, tu sais :
✅ POURQUOI ControlValueAccessor existe (intégration avec Angular Forms)
✅ COMMENT l'implémenter pas à pas
✅ Les 4 méthodes et leur rôle
✅ Comment gérer validation et disabled
✅ Les erreurs à éviter
La règle d'or :
Ton composant devient un vrai input Angular : il fonctionne avec formControlName, [(ngModel)], et toutes les
validations.