# Kirim formulir HTML tanpa reload halaman

> Kirim formulir dengan fetch dan FormData agar pengunjung tetap di halaman, dengan error field, status loading, percobaan ulang, dan cadangan saat script gagal.

Terbit 2026-10-25 · https://ikiform.com/id/guides/html-form-submission/without-page-reload

Dengarkan event `submit` formulir, panggil `preventDefault()`, dan kirim `new FormData(form)` dengan `fetch` dan header `Accept: application/json`. Ikiform lalu menjawab dengan JSON alih-alih redirect: `201` saat sukses, atau error field yang Anda tampilkan di samping setiap input. Pertahankan `action` dan `method` sungguhan di formulir agar tetap terkirim dengan cara biasa jika script Anda tidak pernah berjalan.

Contoh di sini adalah kotak **Was this page helpful?** di bagian bawah halaman dokumentasi. Ini kasus klasik untuk tetap di halaman: pembaca sedang di tengah tugas, dan mengirimnya ke halaman terima kasih hanya untuk mencatat satu klik akan konyol. Pola yang sama bekerja untuk formulir apa pun.

## Apa yang berubah saat Anda mengambil alih submit

Form post biasa memberi Anda banyak hal secara gratis: browser menampilkan indikator loading, membuat klik ganda tidak terlalu berpengaruh, menangani tombol back, dan menampilkan halaman apa pun yang kembali. Begitu Anda memanggil `preventDefault()`, semua itu menjadi tugas Anda. Panggilan fetch-nya sendiri hanya tiga baris; sisa halaman ini adalah pekerjaan yang dulu dilakukan browser.

Diagram: Apa yang dilakukan script saat mengirim formulir dengan fetch alih-alih membiarkan browser bernavigasi

1. Browser menjalankan pemeriksaan batasannya lebih dulu. Event submit hanya terpicu saat formulir valid.
2. Listener Anda memanggil preventDefault, jadi tab tidak bernavigasi.
3. Listener mengirim data formulir dengan fetch dan meminta JSON.
4. Pada 201, listener mengganti formulir dengan pesan status dan memindahkan fokus ke sana.
5. Pada 422 atau kegagalan jaringan, listener menampilkan pesan di samping field, memfokuskannya, dan mengaktifkan pengiriman lagi.

Validasi browser tetap berjalan sebelum kode Anda. Semua setelah preventDefault menjadi tanggung jawab Anda.

| Yang dulu dilakukan browser | Kini script Anda harus |
| --- | --- |
| Menampilkan status loading di tab | Tampilkan satu, dan umumkan ke pembaca layar |
| Membuat klik ganda hampir tidak berbahaya | Abaikan submit kedua selama satu masih berjalan |
| Menampilkan halaman error | Petakan `error.fields` ke setiap input |
| Menampilkan halaman error jaringan | Tangkap request yang gagal dan timeout |
| Memindahkan fokus ke halaman baru | Pindahkan fokus ke hasil atau field salah yang pertama |

## Formulirnya

Tiga field: ya atau tidak yang wajib, komentar opsional, dan alamat halaman yang diisi script. Coba definisi di bawah; itulah yang ditegakkan Ikiform di server, jadi komentar lebih dari 2.000 karakter juga mendapat `422` di sini.

Formulir: Was this page helpful

- **Apakah halaman ini membantu?** (`helpful`, radio, wajib)
- **Apa yang kurang?** (`comment`, textarea)
- **Halaman** (`page`, url)

Tempel ini di tab **Fields** formulir. Dengan field terdefinisi, Ikiform juga menolak field apa pun yang tidak dikenalnya.

`feedback.html`:

```html
<form id="page-feedback" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <fieldset>
    <legend>Was this page helpful?</legend>
    <label><input type="radio" name="helpful" value="yes" required /> Yes</label>
    <label><input type="radio" name="helpful" value="no" /> No</label>
  </fieldset>

  <label for="comment">What was missing?</label>
  <textarea id="comment" name="comment" rows="3" maxlength="2000" aria-describedby="comment-error"></textarea>
  <p id="comment-error" hidden></p>

  <input type="hidden" name="page" />
  <input type="hidden" name="_redirect" value="https://docs.example.com/feedback-sent" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send</button>
  <p id="feedback-status" role="status" tabindex="-1"></p>
</form>

<script type="module" src="/page-feedback.js"></script>
```

Formulir sudah lengkap tanpa script. Dengan JavaScript mati atau diblokir, formulir mengirim dengan cara biasa, Ikiform menjawab `303` dan pembaca mendarat di halaman `_redirect`. Script hanya menyempurnakannya.

## Script-nya

`page-feedback.js`:

```js
const form = document.querySelector("#page-feedback");
const status = form.querySelector("#feedback-status");
const button = form.querySelector("button");
const pageInput = form.elements.namedItem("page");
const idempotencyKey = crypto.randomUUID();
let sending = false;

pageInput.value = location.href;

function showFieldErrors(fields) {
  for (const input of form.querySelectorAll("[aria-invalid]")) input.removeAttribute("aria-invalid");
  for (const hint of form.querySelectorAll("[id$='-error']")) hint.hidden = true;
  let first = null;
  for (const [name, messages] of Object.entries(fields)) {
    const control = form.elements.namedItem(name);
    const hint = form.querySelector(`#${name}-error`);
    if (control instanceof HTMLElement) control.setAttribute("aria-invalid", "true");
    if (hint) {
      hint.textContent = messages[0];
      hint.hidden = false;
    }
    first ??= control instanceof RadioNodeList ? control[0] : control;
  }
  first?.focus();
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  if (sending) return;
  const body = new FormData(form, event.submitter);
  body.set("_idempotency", idempotencyKey);
  sending = true;
  button.setAttribute("aria-disabled", "true");
  status.textContent = "Sending...";

  try {
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body,
      signal: AbortSignal.timeout(15000),
    });
    const result = await response.json();

    if (result.ok) {
      form.replaceChildren(status);
      status.textContent = "Thanks, that helps.";
      status.focus();
      return;
    }
    if (result.error.code === "challenge_required") {
      form.submit();
      return;
    }
    if (result.error.fields) {
      status.textContent = "";
      showFieldErrors(result.error.fields);
    } else if (response.status === 429 && response.headers.has("Retry-After")) {
      const wait = response.headers.get("Retry-After");
      status.textContent = `Too many tries. Wait ${wait} seconds and send again.`;
    } else {
      status.textContent = result.error.message;
    }
  } catch {
    status.textContent = "Couldn't reach the server. Check your connection and send again.";
  } finally {
    sending = false;
    button.removeAttribute("aria-disabled");
  }
});
```

- `new FormData(form, event.submitter)` membaca setiap kontrol bernama, ditambah `name` dan `value` tombol yang diklik jika ada. Susun sebelum Anda mengubah apa pun di formulir.
- Tombol mendapat `aria-disabled`, bukan `disabled`. Flag `sending` yang benar-benar memblokir, dan tombol tetap bisa difokus agar pengguna pembaca layar tidak kehilangan posisinya.
- Tanpa header `Content-Type`. Dengan body `FormData`, browser mengatur `multipart/form-data` dengan boundary yang benar.
- `Accept: application/json` membuat Ikiform menjawab dalam JSON. Panggilan `fetch` mengirim `Sec-Fetch-Mode: cors`, yang toh akan memilih JSON, tetapi header ini memperjelas maksudnya.
- Satu idempotency key per pemuatan halaman, dikirim sebagai field `_idempotency`. Jika pembaca mengirim, jaringan menjatuhkan jawabannya, lalu ia mengirim lagi, Ikiform memutar ulang jawaban pertama alih-alih menyimpan kiriman kedua. Post yang ditolak tidak pernah mengklaim key, jadi kiriman ulang yang sudah diperbaiki tetap diproses. Header `Idempotency-Key` melakukan hal yang sama, tetapi header kustom membuat browser mengirim request preflight lebih dulu. [JavaScript dan fetch](https://ikiform.com/id/docs/frameworks.md#idempotency) berisi aturan persisnya.
- `AbortSignal.timeout(15000)` mengubah request yang macet menjadi cabang catch alih-alih **Sending...** tanpa akhir.
- Baris status punya `role="status"`, jadi teksnya dibacakan tanpa memindahkan fokus. Saat sukses, fokus pindah ke sana karena field di sekitarnya sudah hilang.

## Saat formulir punya pemeriksaan manusia

Jika **Spam check** formulir diatur ke **Ikiform**, post `fetch` tanpa token Turnstile mendapat `403 challenge_required`. Script di atas menanganinya dengan memanggil `form.submit()`. Method itu melewati listener submit dan mengirim dengan cara biasa, dan untuk post biasa Ikiform menampilkan halaman pemeriksaan singkatnya sendiri, lalu lanjut ke halaman `_redirect` Anda. Pembaca hanya meninggalkan halaman saat pemeriksaan aktif.

Agar tetap di halaman bahkan saat itu, dapatkan token lebih dulu dengan `challenge(formId)` dari [SDK](https://ikiform.com/id/docs/sdk.md) dan berikan ke `submit()`. [Dokumentasi spam](https://ikiform.com/id/docs/spam.md#human-check) menunjukkan alurnya.

## Alternatif tanpa JavaScript

Sebelum ada `fetch`, triknya adalah menargetkan iframe tersembunyi, jadi respons dimuat di sana dan halaman tetap di tempat:

`Trik lama`:

```html
<iframe name="feedback-sink" hidden></iframe>
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" target="feedback-sink">
  ...
</form>
```

Trik ini masih berfungsi, tetapi iframe memuat halaman di ikiform.com, dan browser tidak mengizinkan script Anda membaca frame lintas origin. Anda tidak bisa membedakan sukses dari error validasi, jadi pembaca juga tidak mendapat umpan balik apa pun. Gunakan hanya jika script benar-benar tidak bisa berjalan.

> Framework server bisa memberi Anda tanpa reload tanpa kode fetch di klien: server action Next.js, `use:enhance` SvelteKit, dan `<Form>` React Router mengirim dengan cara biasa sebelum halaman ter-hydrate dan tanpa reload sesudahnya. [Ringkasan](https://ikiform.com/id/guides/html-form-submission.md#implementation) menunjukkan masing-masing.

## Pastikan berfungsi

1. Di **Allowed domains** formulir, tambahkan domain situs Anda, ditambah `localhost` selama pengembangan. Biarkan kosong untuk menerima kiriman dari mana saja.
2. Kirim **Yes** tanpa komentar. Formulir berubah menjadi **Thanks, that helps.** dan panel Network menampilkan `201`.
3. Tempel lebih dari 2.000 karakter ke komentar, hapus `maxlength` di developer tools, lalu kirim. Pesan muncul di bawah komentar dan fokus pindah ke sana.
4. Klik dua kali **Send**. Dashboard menampilkan satu kiriman, bukan dua.
5. Matikan JavaScript dan kirim. Anda mendarat di halaman `_redirect`.
6. Atur throttling ke **Offline** dan kirim. Baris status menyatakan tidak bisa menjangkau server, dan **Send** berfungsi lagi.

## Hal yang sering menjebak

| Masalah | Penyebab dan solusi |
| --- | --- |
| Sebuah field hilang dari post | Field itu `disabled` saat `FormData` disusun. Kontrol yang disabled tidak pernah dikirim. Susun data lebih dulu, atau kunci input dengan `readonly` |
| `400 invalid_body` atau kiriman kosong | Kode mengatur `Content-Type: multipart/form-data` secara manual, tanpa boundary. Hapus header itu |
| Halaman tetap dimuat ulang | Listener melempar error sebelum `preventDefault()`, atau klik terjadi sebelum script termuat. Panggil `preventDefault()` lebih dulu; klik awal mengirim dengan cara biasa, yang tetap berfungsi |
| `response.json()` melempar error | Request pergi ke tempat yang menjawab HTML, biasanya `action` relatif. Baca `form.action` di console; harus berupa URL Ikiform Anda |
| `403 challenge_required` | Pemeriksaan manusia formulir aktif. Kembali ke `form.submit()` seperti di atas, atau dapatkan token dengan `challenge()` |
| `403 origin_not_allowed`, atau setiap kiriman masuk ke spam | Domain halaman tidak ada di **Allowed domains** formulir. Tambahkan, ditambah `localhost` untuk uji lokal. Subdomain dari domain yang terdaftar diizinkan. |

## Pertanyaan

### Bagaimana mencegah formulir me-refresh halaman saat submit?

Tambahkan listener submit dan panggil event.preventDefault() di baris pertamanya, lalu kirim datanya sendiri dengan fetch. Dengarkan event submit formulir, bukan click tombol, agar tombol Enter juga tercakup.

### Apakah saya perlu mengatur Content-Type saat mengirim FormData dengan fetch?

Tidak, dan sebaiknya jangan. Browser mengatur multipart/form-data dengan string boundary. Header yang Anda tulis sendiri tidak punya boundary, dan server tidak bisa mem-parsing body.

### Apakah browser tetap memvalidasi formulir jika saya mengirimnya dengan JavaScript?

Ya, selama Anda mendengarkan event submit, yang hanya terpicu setelah pemeriksaan batasan browser lolos. Memanggil form.submit() dari kode melewati pemeriksaan maupun event-nya; form.requestSubmit() menjalankan keduanya.

### Bagaimana mencegah kiriman ganda?

Abaikan submit selama request masih berjalan, dan kirim satu idempotency key per pemuatan halaman, sebagai field _idempotency atau header Idempotency-Key. Jika percobaan ulang tetap sampai ke Ikiform, Ikiform memutar ulang jawaban pertama alih-alih menyimpan kiriman lagi.

## Panduan lainnya

- [Pengiriman formulir HTML](https://ikiform.com/id/guides/html-form-submission.md): Apa yang dilakukan browser saat formulir dikirim: field mana yang dikirim, bagaimana di-encode, apa yang seharusnya dijawab server, dan bagaimana Ikiform menjawab.
- [Validasi formulir](https://ikiform.com/id/guides/html-form-submission/validation.md): Validasi formulir dengan required, type, pattern, dan min, beri gaya error dengan :user-invalid, dan terapkan aturan yang sama di server dengan definisi field.
- [Redirect setelah submit](https://ikiform.com/id/guides/html-form-submission/redirect-after-submit.md): Arahkan pengunjung ke halaman terima kasih Anda sendiri setelah formulir terkirim: redirect 303, field _redirect, domain mana yang diizinkan, dan apa efek refresh.
- [Atribut action](https://ikiform.com/id/guides/html-form-submission/action-attribute.md): Apa fungsi atribut action formulir, bagaimana nilai relatif dan kosong di-resolve, bagaimana formaction mengirim satu formulir ke dua tempat, dan mengapa host statis menjawab 405.
