Angular Reactive Forms: FormGroup, FormBuilder, FormArray ve Validator'lar
Angular 12'de reactive forms: FormControl, FormGroup ve FormBuilder ile formu class'ta kurmak, custom ve async validator yazmak, FormArray ile dinamik alanlar, valueChanges ve patchValue.
Sürüm notu: Bu yazıdaki kod ve komutlar Angular 12 içindir.
Formun Kontrolü Class'a Geçiyor
Daha önce gördüğümüz template-driven formlarda bütün iş template'te dönüyordu. ngModel yazıyordun, validator'ları attribute olarak ekliyordun, Angular da arka planda form nesnesini kendisi kuruyordu. Küçük bir iletişim formu için harika. Ama alan sayısı arttıkça, bir alanın değeri diğerini etkilemeye başladıkça ya da kullanıcı "bir tane daha ekle" diye yeni alanlar açtıkça template şişmeye başlar.
Reactive forms bu işi tersine çevirir. Formun yapısını TypeScript tarafında, class'ın içinde sen kurarsın; template sadece o yapıya bağlanır. Bir mimar gibi düşün: template-driven yaklaşımda evi inşaat alanında gözle kurarsın, reactive yaklaşımda önce masada planı çizersin, ustalar o plana göre örer. Plan elinde olduğu için her an ölçebilir, değiştirebilir ve test edebilirsin.
Bu yazıda küçük bir profil formu kuracağız: ad soyad, e-posta, kullanıcı adı ve dinamik olarak eklenen yetenekler.
ReactiveFormsModule ve İlk FormControl
İlk iş module'e ReactiveFormsModule'ü eklemek. Template-driven formlar için FormsModule kullanmıştık; reactive taraf ayrı bir module ile gelir.
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { ReactiveFormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { ProfileFormComponent } from './profile-form/profile-form.component';
@NgModule({
declarations: [AppComponent, ProfileFormComponent],
imports: [BrowserModule, ReactiveFormsModule],
bootstrap: [AppComponent]
})
export class AppModule { }
Şimdi en küçük yapı taşıyla başlayalım. Tek bir alanı temsil eden şeyin adı FormControl:
import { Component } from '@angular/core';
import { FormControl } from '@angular/forms';
@Component({
selector: 'app-search-box',
template: `
<input type="text" [formControl]="query">
<p>Aranan: {{ query.value }}</p>
`
})
export class SearchBoxComponent {
query = new FormControl('');
}
new FormControl('') boş bir başlangıç değeriyle bir kontrol oluşturur. Template'teki [formControl]="query" ise input'u bu nesneye bağlar. Kullanıcı yazdıkça query.value güncellenir, sen kodda query.setValue('angular') dersen input'un içi değişir. Değer artık template'te değil, class'taki bir nesnede yaşıyor.
FormGroup ile Alanları Bir Araya Getirmek
Gerçek formlar tek alandan oluşmaz. Birden fazla FormControl'ü bir çatı altında toplamak için FormGroup kullanırız:
import { Component } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
@Component({
selector: 'app-profile-form',
templateUrl: './profile-form.component.html'
})
export class ProfileFormComponent {
profileForm = new FormGroup({
fullName: new FormControl(''),
email: new FormControl('')
});
onSubmit(): void {
console.log(this.profileForm.value);
}
}
<form [formGroup]="profileForm" (ngSubmit)="onSubmit()">
<label>Ad Soyad <input formControlName="fullName"></label>
<label>E-posta <input formControlName="email"></label>
<button type="submit">Kaydet</button>
</form>
[formGroup] form etiketini class'taki gruba bağlar. İçerideki her input ise formControlName ile gruptaki kendi anahtarını bulur. Burada name attribute'u ya da ngModel yok; eşleştirme tamamen anahtar isimleri üzerinden yapılıyor. Kaydet'e bastığında konsolda { fullName: '...', email: '...' } şeklinde düz bir nesne görürsün.
FormBuilder ve Validators
Her alan için new FormControl(...) yazmak bir süre sonra kalabalık yapar. FormBuilder service'i aynı yapıyı daha kısa kurmanı sağlar. Bunu yaparken validator'ları da ekleyelim:
import { Component } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
@Component({
selector: 'app-profile-form',
templateUrl: './profile-form.component.html'
})
export class ProfileFormComponent {
profileForm = this.fb.group({
fullName: ['', [Validators.required, Validators.minLength(3)]],
email: ['', [Validators.required, Validators.email]]
});
constructor(private fb: FormBuilder) { }
onSubmit(): void {
if (this.profileForm.invalid) {
this.profileForm.markAllAsTouched();
return;
}
console.log(this.profileForm.value);
}
}
FormBuilder daha önce gördüğümüz constructor injection ile geliyor. fb.group içindeki her dizinin ilk elemanı başlangıç değeri, ikincisi validator listesi. Validators class'ı template-driven formlarda attribute olarak yazdığın kuralların fonksiyon hâlidir: required, minLength, maxLength, email, pattern ve diğerleri.
markAllAsTouched() ise geçersiz form gönderildiğinde bütün alanları "dokunulmuş" sayar, böylece kullanıcı hiç tıklamadığı alanların hatalarını da görür. Hata mesajlarını template'te şöyle gösterebilirsin:
<input formControlName="email">
<p class="error" *ngIf="profileForm.get('email')?.touched && profileForm.get('email')?.hasError('required')">
E-posta zorunlu.
</p>
<p class="error" *ngIf="profileForm.get('email')?.touched && profileForm.get('email')?.hasError('email')">
Geçerli bir e-posta adresi gir.
</p>
get('email') gruptaki kontrolü bulur. Bulamama ihtimali olduğu için dönüş tipi AbstractControl | null ve strict mode açıkken ?. kullanman gerekir. hasError ise belirli bir validator'ın hata verip vermediğini söyler. Template-driven formlarda gördüğün ng-touched, ng-invalid gibi CSS class'ları burada da aynen eklenir.
Kendi Validator'ını Yazmak
Hazır validator'lar her ihtiyacı karşılamaz. Mesela kullanıcı adında boşluk olmasın istiyoruz. Bir validator aslında sıradan bir fonksiyondur: kontrolü alır, sorun yoksa null, sorun varsa bir hata nesnesi döner.
import { AbstractControl, ValidationErrors } from '@angular/forms';
export function noWhitespace(control: AbstractControl): ValidationErrors | null {
const value = (control.value || '') as string;
return value.includes(' ') ? { whitespace: true } : null;
}
Döndürdüğün nesnenin anahtarı, template'te hasError('whitespace') ile soracağın isim olur. Kullanımı da hazır validator'lardan farksız: username: ['', [Validators.required, noWhitespace]].
Bazı kontroller ise anında cevap veremez. "Bu kullanıcı adı alınmış mı?" sorusunun cevabı genelde sunucudadır. Bunun için async validator yazılır; fonksiyon bu kez bir observable döner:
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { delay, map } from 'rxjs/operators';
const takenUsernames = ['admin', 'root', 'mert'];
export function usernameAvailable(): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> =>
of(takenUsernames.includes(control.value)).pipe(
delay(500),
map(taken => (taken ? { usernameTaken: true } : null))
);
}
Burada sunucuyu of ve delay ile taklit ettik; gerçek projede yerine HttpClient çağrısı gelir. Async validator'lar fb.group dizisinde üçüncü eleman olarak verilir: username: ['', [Validators.required, noWhitespace], [usernameAvailable()]]. Angular önce senkron validator'ları çalıştırır; onlar geçerse async olanı başlatır. Cevap beklenirken kontrolün durumu PENDING olur, template'te pending property'siyle "kontrol ediliyor..." yazısı gösterebilirsin.
FormArray ile Dinamik Alanlar
Kullanıcı yeteneklerini tek tek eklesin istiyoruz; kaç tane olacağını önceden bilmiyoruz. FormArray tam bu iş için var: sırası önemli, uzunluğu değişebilen bir kontrol listesi.
profileForm = this.fb.group({
fullName: ['', [Validators.required, Validators.minLength(3)]],
email: ['', [Validators.required, Validators.email]],
username: ['', [Validators.required, noWhitespace], [usernameAvailable()]],
skills: this.fb.array([])
});
get skills(): FormArray {
return this.profileForm.get('skills') as FormArray;
}
addSkill(): void {
this.skills.push(this.fb.control('', Validators.required));
}
removeSkill(index: number): void {
this.skills.removeAt(index);
}
<div formArrayName="skills">
<div *ngFor="let skill of skills.controls; let i = index">
<input [formControlName]="i" placeholder="Yetenek">
<button type="button" (click)="removeSkill(i)">Sil</button>
</div>
</div>
<button type="button" (click)="addSkill()">Yetenek ekle</button>
skills getter'ı, gruptan diziyi doğru tipte almamızı sağlıyor; FormArray importunu da eklemeyi unutma. Template'te formArrayName ile diziye giriyoruz, içerideki her input'u ise isimle değil index ile, [formControlName]="i" şeklinde bağlıyoruz. Butonlara type="button" yazmak önemli; yazmazsan form içindeki her buton varsayılan olarak submit gibi davranır. Formun değeri artık skills: ['TypeScript', 'RxJS'] gibi bir dizi içerir.
valueChanges ile Formu Dinlemek
Reactive forms'un adını hak ettiği yer burası. Her FormControl, FormGroup ve FormArray'in bir valueChanges observable'ı vardır ve değer her değiştiğinde yeni değeri yayar. Formu her değişiklikte taslak olarak kaydedelim:
import { OnDestroy, OnInit } from '@angular/core';
import { Subject } from 'rxjs';
import { debounceTime, takeUntil } from 'rxjs/operators';
export class ProfileFormComponent implements OnInit, OnDestroy {
// profileForm, skills, constructor ... (as above)
private destroy$ = new Subject<void>();
ngOnInit(): void {
this.profileForm.valueChanges
.pipe(debounceTime(500), takeUntil(this.destroy$))
.subscribe(value => {
localStorage.setItem('profileDraft', JSON.stringify(value));
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
Daha önce gördüğümüz RxJS operatörleri burada doğal olarak devreye giriyor. debounceTime(500) kullanıcı yazmayı yarım saniye bırakana kadar bekler, her tuşta kayıt yapmayız. takeUntil(this.destroy$) ise component yok olurken aboneliği kapatır. Aynı yöntemle bir alanın değerine göre başka bir alanın validator'ını açıp kapatabilir, ülke seçilince şehir listesini yenileyebilirsin.
setValue ve patchValue
Formu koddan doldurmanın iki yolu var ve farkları küçük ama önemli:
// Throws: Must supply a value for form control with name: 'username'.
this.profileForm.setValue({ fullName: 'Ayşe Demir', email: 'ayse@ornek.com' });
// Works: updates only the given fields
this.profileForm.patchValue({ fullName: 'Ayşe Demir', email: 'ayse@ornek.com' });
setValue katıdır: gruptaki her alanın değerini eksiksiz vermeni ister, biri eksikse hata fırlatır. Bir form şablonunu baştan sona doldurduğundan emin olmak istediğinde işe yarar. patchValue ise esnektir; verdiğin alanları günceller, gerisine dokunmaz. Sunucudan gelen kısmi bir kullanıcı nesnesini forma basmak ya da yukarıda kaydettiğimiz taslağı geri yüklemek için doğru araç budur.
Küçük bir tuzak: patchValue bir FormArray'e sadece var olan kontroller kadar değer yazar. Taslakta üç yetenek varsa, önce addSkill() ile üç kontrol açman gerekir.
Angular 12'de Form Değerleri Neden any?
this.profileForm.value değerine fare ile gelirsen tipinin any olduğunu görürsün. get('email') ile aldığın kontrolün değeri de, valueChanges'in yaydığı değer de any. Angular 12'de reactive forms tiplendirilmemiştir. fullName yerine yanlışlıkla fullname yazsan derleyici seni uyarmaz, hatayı çalışma zamanında yakalarsın.
Pratikte bunu formdan çıkan değeri kendi interface'ine atayarak yumuşatabilirsin: const profile = this.profileForm.value as Profile;. Bu bir garanti değil, sadece derleyiciye verdiğin bir söz. Angular'ın bu açığı nasıl kapattığını ilerleyen sürüm yazılarında göreceğiz.
Küçük Bir Deneme
Profil formuna bir "Telefonla iletişim" checkbox'ı ve bir telefon alanı ekle. Checkbox işaretlendiğinde telefon alanı zorunlu olsun, işaret kaldırıldığında zorunluluk kalksın. Bunun için checkbox kontrolünün valueChanges'ine abone ol, telefon kontrolünde setValidators ve clearValidators kullan, ardından updateValueAndValidity() çağırmayı unutma.
Bitirdiğinde template'e geçici olarak {{ profileForm.status }} ve {{ profileForm.value | json }} yaz, checkbox'la oynarken formun durumunun nasıl değiştiğini canlı izle. Kullanıcı adına "admin" yazıp PENDING durumunu da yakalamaya çalış.
Form doldururken her tuşta ekranın nasıl güncellendiğini hiç merak ettin mi? Angular değişen bir değeri nereden duyuyor, hangi component'i ne zaman yeniden kontrol ediyor? Sıradaki konumuz change detection ve onu perde arkasında tetikleyen Zone.js.