D
P
0

JavaScript

Badge Booking Nyangkut di Status Memproses? `closest()` Malah Dapat Tombol, Bukan Card

23 Juli 2026·4 menit baca
Badge Booking Nyangkut di Status Memproses? `closest()` Malah Dapat Tombol, Bukan Card

Sebuah platform booking yang saya pegang untuk klien punya daftar kartu booking. Tiap kartu menampilkan badge status dan beberapa tombol aksi: approve, reject, cancel, refund, complete. Alurnya sederhana di atas kertas. User klik salah satu tombol, tombol berubah jadi "Memproses...", server action jalan, lalu badge harusnya langsung berganti status, misalnya dari Confirmed ke Cancelled atau Paid ke Refunded, dan tombol yang tidak relevan lagi menghilang. Semua tanpa reload.

Kenyataannya beda. Setelah aksi apa pun, tombolnya nyangkut di "Memproses..." dan tidak pernah balik. Badge-nya diam di status lama. Anehnya, kalau saya refresh halaman manual, status barunya muncul dengan benar. Jadi server-nya jelas sukses, database sudah berubah. Yang gagal cuma satu: UI tidak pernah mencerminkan perubahan itu secara live.

Gejalanya

Saya reproduksi pelan-pelan. Klik cancel di satu booking. Tombol berganti teks jadi "Memproses...". Lalu tidak ada apa-apa. Badge tetap Confirmed. Tombol tetap di situ, stuck. Saya tunggu beberapa detik, tidak berubah. Begitu saya reload, badge berubah jadi Cancelled dan tombol cancel hilang seperti seharusnya. Pola ini konsisten untuk semua aksi: refund, approve, reject, semuanya sukses di server tapi tidak tercermin di layar sampai reload.

Menelusuri akar masalah

Dugaan pertama saya yang paling wajar: server action-nya diam-diam gagal. Saya buka Network tab, klik ulang, dan lihat POST-nya. Balik 200, response body bilang sukses, dan waktu saya cek database, statusnya memang sudah berubah. Jadi server bukan tersangkanya. Perubahannya nyata, cuma tidak pernah sampai ke DOM.

Dugaan kedua: fungsi yang harusnya update UI tidak pernah dipanggil. Ada fungsi reflectBookingState() yang tugasnya menulis ulang badge dan dataset. Saya taruh console.log di dalam handler klik dan di awal reflectBookingState. Dua-duanya nyala. Jadi fungsinya jalan, argumennya masuk, tidak ada yang di-skip.

Karena fungsinya jalan tapi layar tidak berubah, saya berhenti menebak dan mulai mencetak apa yang sebenarnya dia pegang. Saya log elemen target yang diterima reflectBookingState:

function onActionDone(btn, state) {
  const target = btn.closest('[data-booking-id]');
  console.log('reflect target:', target);
  reflectBookingState(target, state);
}

Yang keluar di console bukan <article class="booking-card"> yang saya harapkan, tapi <button class="booking-action">. Fungsinya meng-update tombol, bukan kartunya. Di detik itu semuanya klik.

Kenapa closest() ketemu tombol

Kuncinya ada di cara Element.closest() bekerja. Dia tidak langsung naik ke induk. Dia mulai dari elemen itu sendiri, cek apakah cocok dengan selektornya, baru naik ke atas kalau belum cocok. Kalau elemen tempat kamu memanggilnya sudah cocok, dia langsung mengembalikan elemen itu juga.

Masalahnya, tombol aksi di markup ini JUGA membawa data-booking-id, dipakai untuk mengirim id booking ke server saat diklik. Jadi baik kartu maupun tombol sama-sama cocok dengan selektor [data-booking-id]:

<article class="booking-card" data-booking-id="812">
  <span class="status-badge">Confirmed</span>
  <button class="booking-action" data-booking-id="812" data-action="cancel">
    Cancel
  </button>
</article>

Waktu handler memanggil btn.closest('[data-booking-id]'), btn itu sendiri sudah cocok, jadi closest() mengembalikan tombol, bukan naik ke article. Lalu reflectBookingState melakukan tugasnya di elemen yang salah:

function reflectBookingState(el, state) {
  if (!el) return;
  el.dataset.state = state;
  const badge = el.querySelector('.status-badge');
  if (badge) badge.textContent = LABELS[state];
}

el.dataset.state di-set di tombol, tidak kelihatan efeknya. Lalu el.querySelector('.status-badge') mencari badge di dalam tombol. Tombol tidak punya anak .status-badge, jadi hasilnya null, guard if (badge) menyelamatkan dari error, dan tidak ada yang berubah. Badge asli yang hidup di dalam article tidak pernah tersentuh. Tidak ada error, tidak ada warning. Cuma update yang mendarat di elemen yang salah.

Perbaikannya

Perbaikannya satu baris: berhenti memilih induk lewat atribut yang juga dipakai anaknya. Pilih kartu lewat selektor yang cuma dimiliki kartu, yaitu class-nya.

function onActionDone(btn, state) {
  const card = btn.closest('.booking-card');
  reflectBookingState(card, state);
}

closest('.booking-card') tidak akan pernah berhenti di tombol, karena tombol bukan .booking-card. Dia naik sampai ke article, dan reflectBookingState akhirnya menyentuh badge yang benar. Saya tes live: refund membalik Confirmed jadi Cancelled dan Paid jadi Refunded, tombol yang tidak relevan lagi hilang, semuanya tanpa reload.

Ada juga perbaikan yang lebih struktural kalau kamu mau menutup celah ini untuk selamanya: taruh data-booking-id hanya di kartu, jangan di tombol. Kalau tombol butuh id-nya, biarkan dia baca dari kartu lewat btn.closest('.booking-card').dataset.bookingId. Satu sumber id, tidak ada dua elemen yang berebut selektor yang sama.

Pelajaran

  • closest() termasuk elemen itu sendiri. Kalau elemen tempat kamu memanggilnya sudah cocok dengan selektor, kamu dapat elemen itu, bukan induknya.
  • Jangan memilih container lewat atribut yang juga dibawa anak-anaknya. Pilih lewat selektor yang unik untuk container, misalnya class yang cuma dimiliki kartu.
  • Kalau fungsi update jalan tapi layar tidak berubah, log elemen yang sebenarnya dia pegang, bukan cuma apakah fungsinya terpanggil.
  • Server balik 200 plus UI stuck biasanya berarti bug-nya ada di langkah reflect di sisi klien, bukan di aksinya.
  • Simpan id di satu elemen saja, lalu baca dari anak lewat closest, daripada menduplikasi atribut ke mana-mana.