Mert Özen Her Satırda Daha İleri
Frontend Angular — #32

Angular Signal Forms Pratikte: form(), Schema, Built-in ve Custom Validator'lar, Async Doğrulama ve submit()

Mert Özen 15 Kas 2026 11 dk 4 görüntülenme
Angular Signal Forms Pratikte: form(), Schema, Built-in ve Custom Validator'lar, Async Doğrulama ve submit()

Signal Forms'ta veri bir model signal'inde durur, form() onun üstüne tipli bir FieldTree kurar. Bookstore checkout form'unu schema, built-in ve custom validator'lar, applyWhen/applyEach, debounce'lu validateHttp ile kupon kontrolü ve sunucu hatalarını field'lere dağıtan submit() ile kuruyoruz.

Sürüm notu: Bu yazıdaki örnekler Angular 22.2, TypeScript 6.0 ve Vitest 4.1 ile denenmiştir.

Önce Tahmin Et

30. yazıda Signal Forms'u deneysel hâliyle, 31. yazıda Angular 22 ile stable olduğu hâliyle kısaca gördük. Örnekler hep tek bir e-posta alanından ibaretti. Bu yazıyla başlayan "Angular 22 ile pratik konular" bölümünde aynı API'yi gerçek bir form üzerinde kullanıyoruz: sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Angular istemcisi bookstore-web'deki checkout sayfası. 15. yazıda bu formu template-driven olarak yazmıştık; şimdi adres, kargo seçimi, sepet satırları, kurumsal sipariş kuralı, sunucuya sorulan kupon kodu ve sunucudan dönen hatalarla baştan kuruyoruz.

Başlamadan bir soru. Formun verisi bir signal'de duruyor ve sen component'in içinde şunu yazıyorsun:

this.order().couponCode = 'BOOK10';

Kupon input'unda ne görünür? Form'un couponCode().value() değeri ne olur? Cevabı "Sık Düşülen Tuzaklar" bölümünde, gerçek test çıktısıyla veriyoruz.

Model ve form(): Veri Nerede Duruyor?

Signal Forms'ta işe formdan değil veriden başlıyorsun. Checkout sayfasının bütün alanlarını tek bir interface'te topladık:

export type ShippingMethod = 'standard' | 'express' | 'pickup';

export interface CheckoutLine {
  isbn: string;
  title: string;
  quantity: number;
}

export interface CheckoutModel {
  fullName: string;
  email: string;
  shipping: ShippingMethod;
  address: { city: string; street: string; zip: string };
  institutional: boolean;
  institutionName: string;
  couponCode: string;
  lines: CheckoutLine[];
}

emptyCheckout() fonksiyonu boş alanlarla ve sepetteki iki kitapla (Dune ve Emma, birer adet) başlangıç değerini döndürüyor. Component bu değeri bir signal'e koyuyor ve form()'u onun üstüne kuruyor:

export class CheckoutFormComponent {
  private readonly orderApi = inject(OrderApiService);
  protected readonly placedOrderId = signal<number | null>(null);
  protected readonly order = signal<CheckoutModel>(emptyCheckout());

  protected readonly checkoutForm = form(this.order, checkoutSchema, {
    submission: { /* aşağıda */ }
  });
}

form() verinin bir kopyasını almıyor. Ürettiği şey bir FieldTree: modelin şeklini aynen izleyen, her field'e checkoutForm.address.zip gibi tipli bir yolla ulaştığın bir ağaç. Bir field'i fonksiyon gibi çağırdığında onun state'ini alıyorsun: value(), valid(), invalid(), errors(), touched(), dirty(), pending(), hidden(), disabled(). Hepsi signal. Input'a yazılan değer doğrudan order signal'ine gidiyor; koddan order.set(...) dersen input güncelleniyor.

32-signal-forms-data-tr.png

Bu yazıdaki fonksiyonların hepsi (form, validate, validateHttp, applyWhen, applyEach, submit, FormRoot) Angular 22.2.6 paketinin tip tanımlarında @publicApi 22.0 olarak işaretli; angular.dev'deki Signal Forms rehberi de 15 Kasım 2026 itibarıyla aynı adları kullanıyor. 30. yazıda gördüğümüz isim değişikliği dönemi bitti.

Schema: Built-in Validator'lar ve Hata Mesajı

Kuralları component'in içine değil, ayrı bir dosyadaki schema'ya yazdık. schema() ile tanımlanan kurallar test edilebilir ve başka bir form'da yeniden kullanılabilir:

export const checkoutSchema = schema<CheckoutModel>((order) => {
  required(order.fullName, { message: 'Full name is required' });
  minLength(order.fullName, 3, { message: 'Enter at least 3 characters' });
  required(order.email, { message: 'Email is required' });
  email(order.email, { message: 'Enter a valid email' });

  required(order.address.city, { message: 'City is required' });
  required(order.address.street, { message: 'Street is required' });
  pattern(order.address.zip, /^[0-9]{5}$/, { message: 'Postal code must be 5 digits' });
  // ...
});

Fonksiyonun parametresi (order) verinin kendisi değil, field'lere giden yolların ağacı. required(order.email) "e-posta field'i zorunlu" demek; o anki değeri okumak için aşağıda göreceğimiz value ve valueOf var.

Template'te her input'u [formField] ile bağladık. Hata mesajlarını her field için ayrı ayrı yazmak yerine küçük bir component'e verdik:

@Component({
  selector: 'app-field-errors',
  template: `
    @if (field()().touched()) {
      @for (error of field()().errors(); track error.kind) {
        <p class="error">{{ error.message }}</p>
      }
    }
  `
})
export class FieldErrorsComponent {
  readonly field = input.required<FieldTree<unknown>>();
}
<form [formRoot]="checkoutForm">
  <label for="email">Email</label>
  <input id="email" type="email" [formField]="checkoutForm.email" />
  <app-field-errors [field]="checkoutForm.email" />
  <!-- diğer field'ler aynı şekilde -->
</form>

field()() ilk bakışta tuhaf duruyor: ilk çağrı input signal'inden FieldTree'yi alıyor, ikincisi o field'in state'ini. <form [formRoot]> ise form'a novalidate ekliyor ve submit event'inde sayfanın yenilenmesini engelleyip form'un submission ayarını çalıştırıyor. Tarayıcıda boş formda "Place order"a bastığımızda, Playwright'ın okuduğu hata listesi şuydu:

after empty submit: ["Full name is required","Email is required","City is required","Street is required"]

Submit denemesi etkileşime açık bütün field'leri touched yaptı, bu yüzden hepsinin mesajı aynı anda göründü. Posta kodu boş olduğu için pattern hata vermedi; pattern boş değeri kontrol etmiyor, zorunluluk ayrı bir kural.

Koşullu Kurallar: hidden, disabled, applyWhen ve applyEach

Checkout'un asıl zor tarafı birbirine bağlı kurallar. Mağazadan teslim alan müşteriden adres istemiyoruz. Kurumsal siparişte (okul, kütüphane, şirket) kurum adı zorunlu, en az 20 kitap gerekiyor ve %10 kurumsal indirim olduğu için kupon kodu kullanılamıyor. Schema'nın geri kalanı bunu anlatıyor:

  hidden(order.address, { when: ({ valueOf }) => valueOf(order.shipping) === 'pickup' });

  applyEach(order.lines, (line) => {
    min(line.quantity, 1, { message: 'Quantity must be at least 1' });
  });

  applyWhen(order, ({ valueOf }) => valueOf(order.institutional), (inst) => {
    required(inst.institutionName, { message: 'Institution name is required' });
    validate(inst.lines, ({ value }) => {
      const books = value().reduce((sum, line) => sum + line.quantity, 0);
      return books < MIN_INSTITUTIONAL_BOOKS
        ? { kind: 'minInstitutionalBooks', message: `Institutional orders need at least ${MIN_INSTITUTIONAL_BOOKS} books (now ${books})` }
        : null;
    });
  });

  disabled(order.couponCode, {
    when: ({ valueOf }) => valueOf(order.institutional) && 'Coupons do not apply to institutional orders'
  });

valueOf(order.shipping) başka bir field'in o anki değerini okuyor ve bu okuma reaktif: kargo seçimi değişince kural kendiliğinden yeniden hesaplanıyor. hidden bir field'i gizli işaretliyor; gizli field parent'ının doğrulamasına katılmıyor. Template'te ise onu senin saklaman gerekiyor: @if (!checkoutForm.address().hidden()). Teslimatı "pickup" yapıp yalnız ad ve e-postayı doldurduğumuzda, adres alanları boş olduğu hâlde checkoutForm().valid() true döndü.

applyEach içindeki kural array'in her satırına ayrı ayrı uygulanıyor. applyWhen ise içindeki bütün kuralları bir koşula bağlıyor. Kurumsal kuralı yazmak için validate() kullandık: kendi validator'ın bir hata object'i ya da null döndürüyor. Hata lines array'inin kendisine ekleniyor, satırlara değil; bu yüzden template'te <app-field-errors [field]="checkoutForm.lines" /> satırı var.

disabled içindeki when true yerine bir string de döndürebiliyor. String, field'in disabledReasons() listesinde mesaj olarak duruyor; biz bunu input'un altına yazdık. Kurumsal siparişi işaretleyip boş formu gönderdiğimizde tarayıcıdaki sonuç:

coupon disabled: true [ 'Coupons do not apply to institutional orders' ]
institutional submit: ["Institutional orders need at least 20 books (now 2)","Institution name is required"]

Dune'un adedini 19 yapınca toplam 20 oldu ve iki hata da kayboldu. Kutuyu kaldırınca kurallar devreden çıktı, kupon input'u yeniden açıldı.

Async Doğrulama: Kupon Kodunu Sunucuya Sormak

Kuponun geçerli olup olmadığını yalnızca sunucu biliyor. validateHttp bir httpResource kurup yanıtı hataya çeviriyor:

  validateHttp(order.couponCode, {
    request: ({ value }) =>
      value() ? `${environment.apiUrl}/coupons?code=${encodeURIComponent(value())}` : undefined,
    debounce: 400,
    onSuccess: (coupons: Coupon[]) =>
      coupons.length ? null : { kind: 'unknownCoupon', message: 'This coupon code is not valid' },
    onError: () => ({ kind: 'couponCheckFailed', message: 'Could not check the coupon, try again' })
  });

request undefined döndürürse istek gönderilmiyor; boş kupon geçerli sayılıyor. Async doğrulama yalnızca aynı field'in senkron kuralları geçtikten sonra çalışıyor. İstek beklerken field'in pending() değeri true oluyor; template'te bunu "Checking the coupon..." yazısıyla gösterdik. onError zorunlu bir alan: sunucuya ulaşılamazsa ne olacağını sen söylüyorsun.

Örnek API'nin db.json dosyasına tek bir kupon ekledik (BOOK10). Tarayıcıda kupon alanına BOOK5 yazıp çıktığımızda önce "Checking the coupon..." göründü, ardından "This coupon code is not valid" mesajı geldi. BOOK10'da hata yoktu. Harfleri tek tek yazdığımız hâlde sunucuya giden istek her kod için bir taneydi:

requests: ["GET /coupons?code=BOOK5","GET /coupons?code=BOOK10","POST /orders","GET /coupons?code=BOOK10"]

Bunu sağlayan debounce: 400: son tuştan 400 milisaniye sonra istek çıkıyor. Listedeki son satıra "Üretimde Dikkat" bölümünde döneceğiz.

submit() ve Sunucu Hataları

Doğrulamanın son sözü yine sunucuda. Bookstore API'si siparişi kabul etmezse 422 ve field bazında hata listesi döndürüyor. form()'a verdiğimiz submission ayarı:

submission: {
  action: async (f) => {
    try {
      const placed = await firstValueFrom(this.orderApi.place(f().value()));
      this.placedOrderId.set(placed.id);
      return undefined;
    } catch (error) {
      return toFieldErrors(error, f);
    }
  }
}
function toFieldErrors(error: unknown, f: FieldTree<CheckoutModel>): TreeValidationResult {
  if (error instanceof HttpErrorResponse && error.status === 422) {
    const targets: Record<string, FieldTree<unknown>> = {
      email: f.email,
      'address.zip': f.address.zip,
      couponCode: f.couponCode
    };
    return (error.error.errors as ApiFieldError[]).map((e) => ({
      kind: 'server',
      message: e.message,
      fieldTree: targets[e.field] ?? f
    }));
  }
  return { kind: 'server', message: 'The order could not be placed, try again' };
}

action yalnızca form geçerliyse çalışıyor. Döndürdüğün hatalardaki fieldTree, hatanın hangi field'in altında görüneceğini belirliyor; vermezsen hata form'un köküne yazılıyor. Çalışırken checkoutForm().submitting() true oluyor, biz butonu bununla kapattık. Formu eksiksiz doldurup gönderince örnek API yeni siparişi kaydetti ve ekranda "Order 1044 placed." yazdı. 422 senaryosunu Vitest testinde HttpTestingController ile ürettik: sunucu address.zip için "We do not deliver to 35000 yet" dedi ve mesaj posta kodu input'unun altına geldi. Test dosyasının sonucu:

 ✓ |bookstore-web| src/app/checkout-form/checkout-form.component.spec.ts > CheckoutFormComponent > shows errors only after the field is touched 38ms
 ✓ |bookstore-web| src/app/checkout-form/checkout-form.component.spec.ts > CheckoutFormComponent > shows every error after a submit attempt 10ms
 ✓ |bookstore-web| src/app/checkout-form/checkout-form.component.spec.ts > CheckoutFormComponent > hides the address for store pickup 10ms
 ✓ |bookstore-web| src/app/checkout-form/checkout-form.component.spec.ts > CheckoutFormComponent > applies the institutional rules only for institutional orders 12ms
 ✓ |bookstore-web| src/app/checkout-form/checkout-form.component.spec.ts > CheckoutFormComponent > checks the coupon once after typing stops 628ms
 ✓ |bookstore-web| src/app/checkout-form/checkout-form.component.spec.ts > CheckoutFormComponent > puts server errors on the matching field 22ms
 Test Files  1 passed (1)
      Tests  6 passed (6)

Projenin bütününde ng test 58 test dosyasında 69 testi geçirdi. Testlerin yazılış yöntemine burada girmiyoruz; Frontend Testleri serisi o konuyu ayrıntılı anlatıyor.

Reactive Forms ile Aynı Form: Ne Zaman Taşımalı?

Aynı kuralların bir kısmını 21. yazıdaki typed Reactive Forms ile yazdık ve bir testte çalıştırdık:

readonly checkoutForm = this.fb.group({
  fullName: ['', [Validators.required, Validators.minLength(3)]],
  email: ['', [Validators.required, Validators.email]],
  institutional: [false],
  couponCode: [''],
  lines: this.fb.array([
    this.fb.group({ isbn: ['9780441172719'], quantity: [1, Validators.min(1)] })
  ])
}, { validators: minInstitutionalBooks });

constructor() {
  this.checkoutForm.controls.institutional.valueChanges.subscribe((institutional) => {
    const coupon = this.checkoutForm.controls.couponCode;
    institutional ? coupon.disable() : coupon.enable();
  });
}

Fark iki yerde belli oluyor. Reactive Forms'ta veri form'un içinde yaşıyor ve "kurumsal siparişte kupon kapalı" kuralı bir valueChanges aboneliğiyle elle yönetiliyor; group validator'ı da group.get('lines') gibi string yollarla çalışıyor. Signal Forms'ta aynı kural schema'da tek satır, veri sıradan bir signal ve yollar tipli.

Ne zaman taşımalı? Yeni yazdığın form'ları Signal Forms ile yaz. Çalışan bir Reactive Forms ekranını sadece "yeni" olduğu için taşıma; o ekrana zaten dokunacaksan, kurallar bir sürü valueChanges aboneliğine dağılmışsa ya da form'un değeri zaten bir signal'den geliyorsa taşı. 33. yazıda göreceğimiz gibi custom control'ler iki dünyada da çalışabildiği için geçiş ekran ekran yapılabiliyor.

Üretimde Dikkat

İstek listesine geri dönelim: POST /orders, son kupon kontrolünden önce gitti. Kurumsal kutusunu kaldırınca kupon field'i yeniden açıldı ve BOOK10 için yeni bir kontrol başladı. 400 milisaniyelik debounce dolmadan formu gönderdik. submit()'in varsayılan ayarı (ignoreValidators: 'pending') bekleyen async doğrulamayı engel saymıyor. Kupon kodu fiyatı değiştirdiği için bu ekranda sunucu kuponu zaten tekrar kontrol etmeli. Sunucuya güvenemediğin bir durumda ignoreValidators: 'none' ile bekleyen doğrulamanın submit'i durdurmasını sağlayabilirsin.

Diğer notlar kısa. Hata mesajlarını schema'da tutmak, çeviri yapılacaksa tek bir yerde toplamayı kolaylaştırıyor (34. yazı). onError'da hatayı yutup kuponu geçerli saymak cazip ama yanlış: kullanıcı indirimi göremez, sipariş sonrası şikâyet gelir. Production build'de bookstore-web'in ilk yüklenen paketi 498.90 kB oldu; bütçe uyarısı 500 kB'ta. Signal Forms paketi artık ana bundle'da, bu sınırı bir sonraki yazılarda izleyeceğiz.

Sık Düşülen Tuzaklar

İlk tuzak, yazının başındaki soru. Bir test component'inde model signal'inin içini doğrudan değiştirdik, sonra aynı değeri update ile verdik:

c.model().code = 'BOOK10';                          // signal'in içi değişti
// model: {"code":"BOOK10"}  form: ""  input: ""
c.model.update((m) => ({ ...m, code: 'BOOK10' }));  // yeni object
// form: "BOOK10"  input: "BOOK10"

Object'in içi değişti ama signal'in referansı aynı kaldığı için ne form ne input bunu gördü. Değeri ya set/update ile yeni bir object vererek ya da checkoutForm.couponCode().value.set(...) ile değiştir.

İkinci tuzak, async validator'ı debounce'suz bırakmak. Aynı validator'ı debounce olmadan kurup BOOK5'i harf harf yazdığımızda dört istek çıktı:

"/coupons?code=B cancelled=true",
"/coupons?code=BO cancelled=true",
"/coupons?code=BOO cancelled=true",
"/coupons?code=BOOK5 cancelled=false",

Resource eski istekleri iptal ettiği için yanlış sonuç ekrana gelmiyor, ama sunucu her tuşta bir istek alıyor. Debounce ile bu sayı bire indi.

Üçüncüsü, her field için ayrı bir computed yazıp "touched ve invalid ise ilk hatanın mesajını göster" mantığını on kez tekrarlamak. Mesaj zaten validator'ın yanında duruyor; FieldErrorsComponent gibi tek bir component o mantığı bir kez yazıyor. Son olarak hidden'ın yalnızca bir işaret olduğunu unutma: field'i template'ten kaldırmak senin işin.

Küçük Bir Deneme

★☆☆ Schema'ya telefon field'i ekle: zorunlu olmasın, ama doluysa pattern(order.phone, /^5[0-9]{9}$/) ile kontrol edilsin. Boş bırakınca hata çıkmadığını, "123" yazınca çıktığını gör.

★★☆ Express kargo seçildiğinde posta kodunu zorunlu yap. required'ın when seçeneğini ve valueOf(order.shipping)'i kullan; standart kargoya dönünce hatanın kaybolduğunu test ile doğrula.

★★★ ignoreValidators: 'none' ayarını submission'a ekle. Kupon kontrolü sürerken "Place order"a bas ve POST /orders'un artık gitmediğini, submit()'in false döndürdüğünü gör.

Bu yazıda adet için düz bir type="number" input kullandık. Bir sonraki yazıda onu −/+ butonlu bir adet stepper'ına çeviriyoruz: FormValueControl ile kendi form control'ümüzü yazacak, aynı component'i Reactive Forms ve ngModel ile de kullanacak, eski kodda karşına çıkacak ControlValueAccessor'ı okuyacağız.