Mert Özen Her Satırda Daha İleri
Frontend JavaScript Orta — #14

JavaScript İleri Formlar: FormData, Constraint Validation API ve Fetch

Mert Özen 3 Kas 2026 9 dk 4 görüntülenme
JavaScript İleri Formlar: FormData, Constraint Validation API ve Fetch

form.elements ve FormData ile değerleri toplamayı, validity ve setCustomValidity ile kendi kurallarını yazmayı, invalid event'ini yakalamayı ve formu sayfa yenilenmeden fetch ile göndermeyi öğreniyoruz.

Sürüm notu: Bu yazıdaki kodlar ES2026 ve güncel tarayıcılar (Chrome, Edge, Firefox, Safari) içindir.

Önce Tahmin Et

Bu kod sayfada hiçbir form olmadan, doğrudan konsolda çalışır. Bir e-posta input'u oluşturuyor, önce geçersiz bir değer veriyor, sonra kendi hata mesajını ekleyip değeri düzeltiyor:

const input = document.createElement("input");
input.type = "email";
input.required = true;

input.value = "ada";
console.log(input.validity.valid, input.validity.typeMismatch);

input.setCustomValidity("This address is not accepted");
input.value = "ada@example.com";
console.log(input.validity.valid);

Konsola ne yazar? Okumaya devam etmeden önce bir tahmin yap. Özellikle son satıra dikkat: değer artık geçerli bir e-posta adresi. Cevap, tarayıcının doğrulama mekanizmasını içeriden gördüğümüz bölümde.

form.elements: Formu Bir Dolap Gibi Açmak

Daha önce formlarla çalışırken her alanı querySelector ile tek tek bulup value ya da checked okuduk. Alan sayısı arttıkça bu yorucu hâle gelir. Form element'inin kendisi sana hazır bir dolap sunar: form.elements. İçindeki her kontrol, name attribute'u ile çekmecesinde durur.

<form id="signup">
  <input name="email" type="email">
  <input type="radio" name="plan" value="free" checked> Free
  <input type="radio" name="plan" value="pro"> Pro
</form>
<script>
  const form = document.querySelector("#signup");
  console.log(form.elements.email.value);   // ""
  console.log(form.elements.plan.value);    // "free"
  console.log(form.elements.length);        // 3
</script>

Aynı name'i taşıyan radio butonları burada güzel bir sürpriz yapar: form.elements.plan tek bir element değil, bir RadioNodeList döndürür ve onun value'su doğrudan seçili olanın değeridir. Hangisinin işaretli olduğunu bulmak için döngü kurmana gerek kalmaz. Bir uyarı: name olarak submit, action ya da reset gibi form property'leriyle çakışan kelimeler kullanırsan bu property'leri gölgelersin; form.submit artık bir method değil, bir input olur.

FormData: Formun Fotoğrafını Çekmek

Bütün değerleri tek seferde almak istediğinde FormData devreye girer. new FormData(form), formun o anki hâlinin fotoğrafını çeker. Fotoğraf çekildikten sonra kullanıcı bir alanı değiştirirse fotoğraf değişmez; yeni bir tane çekmen gerekir.

<form id="order">
  <input name="name" value="Ada">
  <input type="checkbox" name="extra" value="sauce" checked>
  <input type="checkbox" name="extra" value="cheese" checked>
  <input name="coupon" value="DISCOUNT" disabled>
  <input value="field without a name">
</form>
<script>
  const data = new FormData(document.querySelector("#order"));

  console.log(data.get("name"));        // "Ada"
  console.log(data.get("extra"));       // "sauce" (yalnızca ilki)
  console.log(data.getAll("extra"));    // ["sauce", "cheese"]
  console.log(data.has("coupon"));      // false

  for (const [key, value] of data) {
    console.log(key, value);
  }

  data.set("name", "Ada Lovelace");      // varsa değiştirir
  data.append("source", "web");         // yenisini ekler
  console.log(Object.fromEntries(data)); // { name: "Ada Lovelace", extra: "cheese", source: "web" }
</script>

Bu örnekte formdaki birkaç kural birden görünüyor. name'i olmayan alan fotoğrafa girmez. disabled alan da girmez; değeri gösterip gönderilmesini istemiyorsan bu işine yarar, gönderilsin ama düzenlenmesin istiyorsan readonly kullan. İşaretsiz checkbox hiç görünmez, yani "false" diye bir değer gelmez; alanın yokluğu cevabın kendisidir. Aynı isimli birden çok değer varsa get ilkini, getAll hepsini verir.

Object.fromEntries(data) çok pratik ama aynı isimli değerlerde sonuncusu kazanır; extra'nın "sauce" değerini kaybettiğimize dikkat et. Dosya seçilen bir <input type="file"> varsa get sana bir File object'i verir; dosyalarla çalışmayı ilerleyen yazılarda detaylandıracağız.

Tarayıcının İçindeki Doğrulayıcı

Daha önce required, minlength ve type="email" gibi attribute'larla tarayıcıya doğrulama yaptırdık. Bu attribute'ların arkasında JavaScript'ten de konuşabildiğin bir mekanizma var: Constraint Validation API. Her form kontrolünün bir validity property'si vardır ve bu object kontrolün hangi kuralı çiğnediğini tek tek söyler.

Bir sınav kâğıdı gibi düşün. validity kâğıttaki her soruya ayrı bir işaret koyar: valueMissing (zorunlu alan boş), typeMismatch (e-posta ya da URL biçimi yanlış), patternMismatch (pattern regex'ine uymuyor), tooShort ve tooLong (uzunluk), rangeUnderflow ve rangeOverflow (min/max), stepMismatch (step'e uymuyor), badInput (tarayıcı değeri çözümleyemedi) ve customError (senin koyduğun hata). En altta da genel not: valid. Bütün işaretler temizse valid true olur.

Bunlara ek olarak her kontrolde şu method ve property'ler var. checkValidity() true ya da false döner; false dönerse kontrol üzerinde bir invalid event'i de tetiklenir. reportValidity() aynı kontrolü yapar ve sorunu kullanıcıya tarayıcının kendi baloncuğuyla gösterir. validationMessage tarayıcının, kullanıcının diline göre ürettiği hata metnidir. Bu method'lar form element'inin kendisinde de var: form.checkValidity() bütün alanları birden kontrol eder.

Şimdi baştaki soruya dönelim. İlk satır false true yazar: "ada" bir e-posta biçiminde değil. İkinci satır ise false. Değer düzeldi ama setCustomValidity ile koyduğun hata hâlâ orada; customError işareti sen silene kadar kalır. Tarayıcı senin kuralını kendi kurallarından bağımsız tutar ve onu ne zaman kaldıracağını bilemez.

setCustomValidity ile Kendi Kuralın

Yerleşik attribute'ların ifade edemediği kurallar için setCustomValidity(message) var. Boş olmayan bir mesaj verdiğin anda alan geçersiz sayılır, form gönderilmez ve tarayıcı baloncukta senin mesajını gösterir. Alanı tekrar geçerli yapmanın tek yolu boş string vermektir: setCustomValidity(""). Klasik örnek, "şifre tekrarı" alanı:

const password = form.elements.password;
const confirmField = form.elements.confirm;

function checkMatch() {
  if (confirmField.value !== password.value) {
    confirmField.setCustomValidity("Passwords don't match");
  } else {
    confirmField.setCustomValidity("");
  }
}

password.addEventListener("input", checkMatch);
confirmField.addEventListener("input", checkMatch);

Kontrolü her iki alanın da input event'ine bağladığımıza dikkat et. Yalnızca confirmField'e bağlasaydık, kullanıcı ilk şifreyi sonradan değiştirdiğinde eski hata ya da eski "geçerli" durumu yerinde kalırdı.

input, change ve invalid Event'leri

Form alanlarında üç event sık karşına çıkar. input, değer her değiştiğinde tetiklenir: her tuş vuruşunda, her yapıştırmada. change ise değişiklik "kesinleştiğinde" gelir: metin alanında kullanıcı alandan çıkınca, checkbox, radio ve select'te ise seçim yapıldığı anda. Anlık arama ya da karakter sayacı için input, "alan bitti, şimdi kontrol et" türü işler için change doğru seçimdir.

invalid ise bir alan doğrulamadan geçemediğinde, yani checkValidity(), reportValidity() çağrıldığında ya da form gönderilmeye çalışıldığında tetiklenir. Burada bir yakalanma noktası var: invalid kabarcıklanmaz. Forma bağladığın sıradan bir listener alanlardan gelen invalid'i duymaz. Daha önce gördüğümüz capturing aşaması tam burada işe yarıyor: listener'ı forma { capture: true } ile bağlarsan event inerken yakalarsın ve bütün alanlar için tek bir listener yeter.

Tarayıcının baloncuklarını değil kendi hata mesajlarını göstermek istiyorsan forma novalidate attribute'u eklersin. Bu, gönderim sırasındaki otomatik kontrolü ve baloncukları kapatır ama validity ve checkValidity() çalışmaya devam eder. Yani kuralları yine tarayıcı denetler, sonucu sen gösterirsin. CSS tarafında da :user-invalid selector'ü, alanı kullanıcı etkileşime girdikten sonra işaretler; sayfa açılır açılmaz boş zorunlu alanları kırmızıya boyayan :invalid'den çok daha nazik bir deneyim verir.

Hepsi Bir Arada: Doğrulayan ve Fetch ile Gönderen Form

Şimdi parçaları birleştirelim. Aşağıdaki dosyayı kaydedip tarayıcıda aç. Form tarayıcının kurallarını kullanıyor, hataları kendi alanlarında gösteriyor ve geçerliyse sayfayı yenilemeden gönderiyor. Hedef olarak, gelen isteği olduğu gibi geri döndüren herkese açık bir test service'i kullanıyoruz.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Sign-up form</title>
  <style>
    .error { color: crimson; font-size: 0.9em; }
    input:user-invalid { border-color: crimson; }
  </style>
</head>
<body>
  <form id="signup" novalidate>
    <p><input name="email" type="email" required placeholder="Email">
       <span class="error" data-for="email"></span></p>
    <p><input name="password" type="password" required minlength="8" placeholder="Password">
       <span class="error" data-for="password"></span></p>
    <p><input name="confirm" type="password" required placeholder="Repeat password">
       <span class="error" data-for="confirm"></span></p>
    <button>Sign up</button>
    <p id="status"></p>
  </form>
  <script>
    const form = document.querySelector("#signup");
    const password = form.elements.password;
    const confirmField = form.elements.confirm;
    const status = document.querySelector("#status");

    function showError(field) {
      const slot = form.querySelector(`[data-for="${field.name}"]`);
      if (slot) slot.textContent = field.validationMessage;
    }

    function checkMatch() {
      confirmField.setCustomValidity(confirmField.value === password.value ? "" : "Passwords don't match");
    }

    password.addEventListener("input", checkMatch);
    confirmField.addEventListener("input", checkMatch);

    // invalid kabarcıklanmaz, capture ile tek listener yeter
    form.addEventListener("invalid", (event) => showError(event.target), { capture: true });
    // Hata görünen alanların mesajını kullanıcı yazdıkça güncelle
    form.addEventListener("input", () => {
      for (const field of form.elements) {
        const slot = form.querySelector(`[data-for="${field.name}"]`);
        if (slot?.textContent) showError(field);
      }
    });

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      checkMatch();
      if (!form.checkValidity()) return;

      const button = form.querySelector("button");
      button.disabled = true;
      status.textContent = "Sending...";

      try {
        const response = await fetch("https://httpbin.org/post", {
          method: "POST",
          body: new FormData(form)
        });
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const result = await response.json();
        console.log("Fields that reached the server:", result.form);
        status.textContent = "Sign-up received.";
        form.reset();
      } catch (error) {
        status.textContent = `Could not send: ${error.message}`;
      } finally {
        button.disabled = false;
      }
    });
  </script>
</body>
</html>

Akışı adım adım okuyalım. submit'te önce varsayılan gönderimi durduruyoruz, ardından form.checkValidity() çağırıyoruz. Bu çağrı her geçersiz alan için bir invalid event'i tetikler; capture ile bağladığımız listener da her birinin validationMessage'ını kendi span'ine yazar. Kullanıcı herhangi bir alana yazdığında forma bağlı input listener'ı, o an hata gösteren bütün alanların mesajını yeniler. Alan düzeldiyse mesaj kendiliğinden kaybolur, çünkü geçerli bir alanın validationMessage'ı boş string'dir. Tek bir alana değil bütün forma bakmamızın sebebi şu: şifreyi değiştirmek, tekrar alanını da geçerli ya da geçersiz yapabilir. Hata metinlerini textContent ile yazmamız da bilinçli: daha önce gördüğümüz XSS riskini sıfırda tutar.

Formu Fetch ile Göndermenin Üç Yolu

Daha önce gördüğümüz fetch'e body olarak doğrudan bir FormData verirsen tarayıcı isteği multipart/form-data olarak kodlar. Bu, dosya yüklemelerini de taşıyabilen biçimdir. Burada çok yaygın bir hata var: Content-Type header'ını elle yazmamalısın. Tarayıcı bu header'a parçaları ayıran bir boundary değeri ekler; sen elle yazarsan boundary kaybolur ve sunucu isteği çözemez.

const data = new FormData(form);

// 1. multipart/form-data (dosyalar dahil)
fetch(url, { method: "POST", body: data });

// 2. application/x-www-form-urlencoded (klasik form biçimi, dosyasız)
fetch(url, { method: "POST", body: new URLSearchParams(data) });

// 3. JSON (API JSON bekliyorsa)
fetch(url, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(Object.fromEntries(data))
});

Hangisini seçeceğin sunucunun ne beklediğine bağlı. JSON yolunu seçersen Object.fromEntries'in aynı isimli değerleri ezdiğini unutma; çoklu checkbox varsa o alanı getAll ile ayrıca ekle. Bir de formu koddan göndermen gerekirse form.submit() yerine form.requestSubmit() kullan. submit() doğrulamayı atlar ve submit event'ini hiç tetiklemez; yani az önce yazdığın bütün mantığın üzerinden geçer. requestSubmit() ise kullanıcı butona basmış gibi davranır.

Sık Düşülen Tuzaklar

setCustomValidity'yi temizlemeyi unutmak. Kullanıcı hatayı düzeltir, alan yine geçersiz görünür ve form asla gönderilmez. Kural koyduğun her yerde, kuralın sağlandığı durumda "" verdiğinden emin ol.

name'siz alan. Ekranda görünür, kullanıcı doldurur, ama FormData'ya hiç girmez. Sunucuya giden verinin eksik olduğunu genelde en son fark edersin.

invalid'i forma capture olmadan dinlemek. Listener hiç çalışmaz ve hiçbir hata da vermez.

Doğrulamayı yalnızca tarayıcıya bırakmak. İstemci tarafındaki doğrulama kullanıcı deneyimi içindir, güvenlik için değil. DevTools'u açan herkes novalidate ekleyebilir ya da isteği elle gönderebilir; aynı kuralları sunucu da mutlaka kontrol etmeli.

Küçük Bir Deneme

★☆☆ Örnek forma <input name="age" type="number" min="18" max="120"> ekle. Önce 15, sonra 200 yazmayı dene ve her seferinde konsolda form.elements.age.validity'nin hangi işaretinin true olduğuna bak.

★★☆ Çıktıyı tahmin et: aynı forma <input type="checkbox" name="terms"> ekle ve işaretlemeden new FormData(form).get("terms") çalıştır. Sonra işaretleyip tekrar dene. İki sonuç ne olur ve neden?

★★★ Kullanıcı adı alanı için change event'inde sahte bir "bu ad alınmış mı?" kontrolü yaz: "admin" ya da "root" girilirse setCustomValidity ile hata ver. Sonra bunu input event'ine taşı ve iki yaklaşımın kullanıcıya nasıl hissettirdiğini karşılaştır.

Kullanıcı uzun bir formu yarıya kadar doldurup sekmeyi yanlışlıkla kapatırsa ne olur? Şu an her şey kaybolur. Bir sonraki yazıda tarayıcının sana verdiği küçük depolara bakıyoruz: localStorage, sessionStorage ve cookie'ler; neyi nerede saklayabileceğini ve neyi asla saklamaman gerektiğini konuşacağız.