Angular'da Kendi Form Control'ünü Yazmak: FormValueControl, FormCheckboxControl ve Eski Kodda ControlValueAccessor
Angular 22'de bir component'in form control olması için value = model() yeterli: FormValueControl ve FormCheckboxControl ile adet stepper'ı ve hediye paketi düğmesi yazıyor, aynı control'ü Reactive Forms ve ngModel ile kullanıyor, eski ControlValueAccessor kodunu okuyup taşıyoruz.
Sürüm notu: Bu yazıdaki örnekler Angular 22.2, TypeScript 6.0, Vitest 4.1 ve Playwright 1.65 ile denenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Angular istemcisi bookstore-web'de 24. yazıdan beri bir yıldız puanı component'i var. 26. yazıda onun @Input/@Output çiftini model()'e çevirmiştik:
export class BookRatingComponent {
max = input(5);
compact = input(false, { transform: booleanAttribute });
disabled = input(false, { transform: booleanAttribute });
value = model(0);
rated = output<number>();
// ...
}
Bu yazının konusu kendi form control'ünü yazmak, ve Signal Forms'ta bir component'in form control olması için value adında bir model() yeterli. Class'a yalnızca implements FormValueControl<number> eklesen derlenir mi? Cevap birazdan, gerçek derleyici çıktısıyla.
32. yazıda checkout form'undaki adetler düz type="number" input'tu. Bu yazıda onları −/+ butonlu bir adet stepper'ına çeviriyoruz, hediye paketi için bir açma-kapama düğmesi ekliyoruz ve eski projelerde karşına çıkacak ControlValueAccessor'ı okuyoruz.
FormValueControl: Adet Stepper'ı
@Component({
selector: 'app-quantity-stepper',
styleUrl: './quantity-stepper.component.css',
host: { role: 'group', '[attr.aria-label]': 'label()', '(focusout)': 'onFocusOut($event)' },
template: `
<button type="button" aria-label="Decrease quantity"
[disabled]="disabled() || atMin()" (click)="step(-1)">−</button>
<input type="number" [attr.aria-label]="label()" [value]="value()"
[disabled]="disabled()" [attr.aria-invalid]="invalid()"
(input)="onInput($event)" />
<button type="button" aria-label="Increase quantity"
[disabled]="disabled() || atMax()" (click)="step(1)">+</button>
`
})
export class QuantityStepperComponent implements FormValueControl<number> {
// Signal Forms bunları field'in state'inden doldurur
readonly value = model(1);
readonly disabled = input(false);
readonly invalid = input(false);
readonly errors = input<readonly ValidationError[]>([]);
readonly min = input<number | undefined>(undefined);
readonly max = input<number | undefined>(undefined);
readonly touch = output<void>();
readonly label = input('Quantity');
protected readonly atMin = computed(() => this.min() !== undefined && this.value() <= this.min()!);
protected readonly atMax = computed(() => this.max() !== undefined && this.value() >= this.max()!);
protected step(delta: number): void {
this.value.update((q) => q + delta);
}
protected onInput(event: Event): void {
const raw = (event.target as HTMLInputElement).valueAsNumber;
this.value.set(Number.isNaN(raw) ? 0 : raw);
}
protected onFocusOut(event: FocusEvent): void {
// odak component'in tamamından çıktıysa touched bildir
const host = event.currentTarget as HTMLElement;
if (!host.contains(event.relatedTarget as Node | null)) {
this.touch.emit();
}
}
}
FormValueControl<number>'ın tek zorunlu üyesi value: bir ModelSignal<number>. [formField] directive'i bu model'i field'in değerine iki yönlü bağlıyor. Component value.update(...) dediğinde değer form'a, form'un modeli değiştiğinde de component'e geliyor. Arada senin yazdığın tek satır bağlama kodu yok.
Checkout template'inde değişiklik iki satır:
@for (line of checkoutForm.lines; track line) {
<span>{{ line.title().value() }}</span>
<app-quantity-stepper [formField]="line.quantity" [label]="'Quantity of ' + line.title().value()" />
<app-field-errors [field]="line.quantity" />
}
Schema'dan Gelen State: min, disabled, invalid ve touch
Geri kalan property'lerin hepsi opsiyonel ve FormUiControl interface'inden geliyor: disabled, readonly, hidden, invalid, errors, pending, touched, dirty, required, min, max, minLength, maxLength, pattern, name. Component'te bu adla bir input tanımlarsan [formField] field'in o anki state'ini oraya yazıyor. Tanımlamadıklarını yok sayıyor.
Bunun pratik karşılığı şu: 32. yazıdaki schema'da min(line.quantity, 1) kuralı vardı. Stepper bu kuralı ayrıca bilmiyor; min input'u üzerinden 1 değerini alıyor ve adet 1 iken "−" butonunu kapatıyor. Kural bir yerde yazılı, hem doğrulama hem arayüz ondan besleniyor. Vitest testinde bunu böyle kontrol ettik:
expect(buttons(el)[0].disabled).toBe(true);
buttons(el)[1].click();
await fixture.whenStable();
expect(fixture.componentInstance.line()).toEqual({ quantity: 2 });
expect(buttons(el)[0].disabled).toBe(false);
touched farklı çalışıyor. Bir input olarak touched bilgisini [formField]'den alabilirsin, ama field'i touched yapan şey control'ün kendisi: touch adında bir output tanımlıyorsun ve kullanıcı control'den çıktığında emit ediyorsun. Angular'ın tip tanımlarındaki not bunu focus'ta değil blur'da yapmanı söylüyor. Stepper'da üç odaklanabilir element var; her birinin blur'unda emit edersek "−"den input'a geçerken bile field touched olur. Bu yüzden host'taki focusout'u dinledik ve yalnızca odak component'in tamamen dışına çıktığında touch'ı tetikledik.
FormCheckboxControl: Hediye Paketi Düğmesi
Değeri true/false olan control'ler için ayrı bir interface var. Farkı, value yerine checked adında bir model istemesi:
@Component({
selector: 'app-gift-wrap-toggle',
template: `
<button type="button" role="switch" [attr.aria-checked]="checked()"
[disabled]="disabled()" (click)="checked.set(!checked())" (blur)="touch.emit()">
Gift wrap: {{ checked() ? 'on' : 'off' }}
</button>
`
})
export class GiftWrapToggleComponent implements FormCheckboxControl {
readonly checked = model(false);
readonly disabled = input(false);
readonly touch = output<void>();
}
Checkout modeline giftWrap: boolean ekledik; schema'ya da mağazadan teslimde hediye paketini kapatan bir kural yazdık:
disabled(order.giftWrap, {
when: ({ valueOf }) => valueOf(order.shipping) === 'pickup' && 'Gift wrap is not available for store pickup'
});
Tarayıcıda düğmeye tıkladıktan sonra Playwright'ın erişilebilirlik ağacındaki görüntüsü switch "Gift wrap: on" [checked] oldu; teslimatı "pickup" yapınca düğme disabled oldu. Bir control'de value ve checked aynı anda olamıyor: biri FormValueControl, diğeri FormCheckboxControl.
Mevcut Bir Component'i Bağlamak: max Çakışması
Şimdi baştaki soru. BookRatingComponent'e implements FormValueControl<number> ekleyip ng test çalıştırdığımızda build şu hatayla durdu:
✘ [ERROR] TS2416: Property 'max' in type 'BookRatingComponent' is not assignable to the same property in base type 'FormValueControl<number>'.
Type 'InputSignal<number>' is not assignable to type 'InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown> | undefined'.
Component'teki max "kaç yıldız çizilecek" anlamına geliyordu; FormUiControl'deki max ise "izin verilen en büyük değer" ve undefined olabilmesi gerekiyor. implements satırını yazmasaydık ne olurdu? O zaman da [formField] ile bağladığımız template TS2322 verdi, çünkü directive max input'una field'in number | undefined değerini yazmaya çalışıyor. Yani interface'i yazmasan bile Angular aynı adları kullanıyor.
Çözüm adı değiştirmek. max'ı starCount yaptık (projede [max] ile kullanan bir yer yoktu), field'in touched olabilmesi için touch output'unu ekledik ve her yıldız butonunun blur'unda emit ettik. disabled input'u booleanAttribute transform'uyla tanımlıydı; interface InputSignalWithTransform'u da kabul ettiği için ona dokunmadık. Sonrasında [formField] ile bağlanan component beş yıldız çizdi, dördü dolu; ikinci yıldıza tıklayınca model { stars: 2 } oldu.
Kendi control'ünü yazarken input adlarını bu listeye göre seç: form'la ilgisi olmayan bir şeye min, max, name, required ya da pattern deme.
Aynı Component Reactive Forms ve ngModel ile
angular.dev'deki custom controls rehberi (15 Kasım 2026'da okuduğumuz hâliyle) bu control'lerin Signal Forms, Reactive Forms ve template-driven form'larda ek uyumluluk kodu olmadan çalıştığını söylüyor. Bunu stepper için test ettik:
@Component({
imports: [QuantityStepperComponent, ReactiveFormsModule],
template: `<form [formGroup]="group"><app-quantity-stepper formControlName="quantity" /></form>`
})
class ReactiveHost {
readonly group = new FormGroup({ quantity: new FormControl(3, { nonNullable: true }) });
}
@Component({
imports: [QuantityStepperComponent, FormsModule],
template: `<app-quantity-stepper [(ngModel)]="quantity" />`
})
class TemplateHost {
quantity = 2;
}
Reactive host'ta input "3" gösterdi, "+" ile FormControl'ün değeri 4 oldu, focusout'tan sonra control.touched true döndü ve control.disable() butonları kapattı. ngModel host'unda input "2" gösterdi, "−" ile property 1 oldu. Stepper'da tek satır ControlValueAccessor yok. 32. yazıda "geçişi ekran ekran yapabilirsin" derken kastettiğimiz buydu: yeni control'ü bir kez yazıyorsun, Reactive Forms'ta kalan ekranlar da kullanabiliyor.
Eski Kodda ControlValueAccessor
Angular 22 öncesinde yazılmış her custom control'de şuna benzer bir kod göreceksin. Aynı stepper'ın o dönemki hâli:
@Component({
selector: 'app-legacy-quantity-stepper',
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => LegacyQuantityStepperComponent),
multi: true
}],
template: `
<button type="button" [disabled]="disabled()" (click)="step(-1)" (blur)="onTouched()">−</button>
<span>{{ quantity() }}</span>
<button type="button" [disabled]="disabled()" (click)="step(1)" (blur)="onTouched()">+</button>
`
})
export class LegacyQuantityStepperComponent implements ControlValueAccessor {
protected readonly quantity = signal(1);
protected readonly disabled = signal(false);
private onChange: (value: number) => void = () => {};
protected onTouched: () => void = () => {};
// form → component: yalnızca ekranı güncelle, onChange çağırma
writeValue(value: number | null): void {
this.quantity.set(value ?? 1);
}
registerOnChange(fn: (value: number) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled.set(isDisabled);
}
protected step(delta: number): void {
this.quantity.update((q) => Math.max(1, q + delta));
// component → form
this.onChange(this.quantity());
}
}
Okurken dört method'u ve bir provider'ı tanıman yeterli. NG_VALUE_ACCESSOR provider'ı, formControl, formControlName ya da ngModel'e "bu element'in değerini ben yönetiyorum" diyor; forwardRef class henüz tanımlanmadan ona referans verebilmek için. writeValue form'dan component'e giden yön. registerOnChange ve registerOnTouched ile form sana iki callback veriyor; değer değişince birini, kullanıcı çıkınca diğerini çağırıyorsun. setDisabledState da control.disable()'ın component'e ulaştığı yer.
Taşırken eşleme düz: writeValue ile onChange çifti tek bir value = model() oluyor, setDisabledState bir disabled input'u, onTouched bir touch output'u. Provider ve forwardRef tamamen gidiyor. Eski component'in formControl ile çalıştığını gösteren testi tutup aynı testi yeni component'e karşı da çalıştırırsan, taşımanın davranışı değiştirmediğini görürsün.
Klavye, Label ve Erişilebilirlik
Kendi control'ünü yazınca tarayıcının ücretsiz verdiği klavye ve ekran okuyucu desteğini de sen üstleniyorsun. Stepper'da bunu azaltmak için ortadaki değeri native bir type="number" input olarak bıraktık. Playwright'ın erişilebilirlik ağacı:
- group "Quantity of Dune":
- button "Decrease quantity" [disabled]: −
- spinbutton "Quantity of Dune": "1"
- button "Increase quantity": +
Input'a odaklanıp yukarı ok tuşuna basınca değer 3'ten 4'e çıktı; bunu biz yazmadık, native input'tan geldi. "+" butonuna odaklanıp iki kez Enter'a basınca değer 1'den 3'e çıktı. Tab sırası "Quantity of Dune","Increase quantity","Quantity of Emma" şeklindeydi; disabled "−" butonu sıraya girmedi.
Label konusu ayrı bir dikkat istiyor. Dışarıdaki bir <label>, içinde birden fazla odaklanabilir element olan bir component'i tek başına isimlendiremiyor. Biz label input'unu hem host'taki role="group"'a hem iç input'a aria-label olarak verdik.
Sıfırdan yazılan BookRatingComponent'te ise ilk bakışta durum kötüydü: ağaçta beş tane button "★" vardı, ekran okuyucu kullanıcısı hangisinin kaç puan olduğunu duyamıyordu. Her butona [attr.aria-label]="'Rate ' + star + ' of ' + starCount()" ekledik ve ağaç button "Rate 1 of 5" … button "Rate 5 of 5" oldu. Yıldız puanı gibi listbox, combobox ya da tabs davranışı gereken control'lerde klavye etkileşimini baştan yazmak yerine 30. yazıda gördüğümüz Angular Aria directive'lerine ya da Angular CDK'ya (Component Dev Kit; Angular ekibinin component yazmak için sunduğu davranış araçları) bak. Angular Material ve CDK serisinde bunlar ayrıntılı geçecek; ARIA'nın (ekran okuyuculara rol ve durum bilgisi veren HTML attribute'ları) kendisi Accessibility (a11y) serisinin konusu.
Üretimde Dikkat
Yeni component'leri ekleyince ng build ilk kez bütçe uyarısı verdi:
▲ [WARNING] bundle initial exceeded maximum budget. Budget 500.00 kB was not met by 1.83 kB with a total of 501.83 kB.
Checkout sayfası ana sayfaya girenlerin çoğu için gereksiz. Route'u loadComponent ile lazy yaptık:
{
path: 'checkout',
loadComponent: () => import('./checkout-form/checkout-form.component').then(m => m.CheckoutFormComponent),
title: 'Checkout'
},
İlk yüklenen paket 449.20 kB'a indi; checkout ve Signal Forms kodu 52.67 kB'lık ayrı bir chunk'a geçti. Bir control kütüphanesi yazıyorsan şunu da hatırla: FormUiControl'de opsiyonel bir focus() method'u var. Tip tanımlarındaki açıklamaya göre bunu tanımlamazsan Signal Forms, control'e odaklanması istendiğinde host element'e odaklanmaya çalışıyor. Stepper'ın host'u odaklanabilir bir element değil; böyle bir component'te iç input'a odaklayan kısa bir focus() yazmak mantıklı.
Sık Düşülen Tuzaklar
Eski bir projede ControlValueAccessor okurken en sık gördüğümüz üç hatayı tek bir component'te topladık: onTouched hiç çağrılmıyor, writeValue içinde onChange çağrılıyor ve setDisabledState yok. Test sonucunda okunan değerler:
"buttonDisabled": false,
"dirtyAfterInit": false,
"dirtyAfterSetValue": true,
"emitted": [
7,
7,
7,
],
"touched": false,
Kod control.setValue(7) dedi ve kullanıcı hiçbir şeye dokunmadığı hâlde control dirty oldu; "kaydedilmemiş değişiklik var" uyarısı gösteren bir guard bu ekranda boşuna devreye girer. valueChanges aynı değeri bir yerine üç kez yayınladı. Butondan blur ile çıkıldığı hâlde touched false kaldı; hata mesajını "touched ise göster" diye yazdıysan kullanıcı hatayı hiç görmez. control.disable()'dan sonra buton tıklanabilir kaldı.
Signal Forms tarafında karşılıkları daha az ama var: touch output'unu tanımlamamak aynı "hata görünmüyor" sorununu doğuruyor, disabled input'unu tanımlayıp template'te kullanmamak da kuralı sessizce yok sayıyor. Bir de yukarıda gördüğümüz isim çakışması.
Küçük Bir Deneme
★☆☆ Stepper'a bir max kuralı bağla: schema'ya max(line.quantity, 10) ekle ve adet 10'a gelince "+" butonunun kapandığını gör. Stepper'ın kodunda hiçbir şey değiştirmen gerekmeyecek.
★★☆ Kurumsal sipariş için bir birim seçici yaz: FormValueControl<string>, değer olarak kurumun birim adını (ör. "Main Campus", "Library") tutsun ve native bir select'i sarsın. Aynı component'i hem [formField] hem formControlName ile test et.
★★★ LegacyQuantityStepperComponent'in testini kopyalayıp QuantityStepperComponent'e karşı çalıştır. Hangi satırları değiştirmen gerektiğini not et; bu liste gerçek bir projede taşıma kontrol listen olur.
Checkout'taki bütün metinler İngilizce. Bookstore'un asıl müşterileri Türkiye'de. Bir sonraki yazıda @angular/localize ile arayüzü Türkçe ve İngilizce yapıyor, ng extract-i18n ile çeviri dosyasını çıkarıyor ve her dil için ayrı build alıyoruz.