D
P
0

JavaScript

Booking Badge Stuck on Processing? `closest()` Returned the Button, Not the Card

July 23, 2026·4 min read
Booking Badge Stuck on Processing? `closest()` Returned the Button, Not the Card

A booking platform I was building for a client had a list of booking cards. Each card shows a status badge and a few action buttons: approve, reject, cancel, refund, complete. The flow looks trivial on paper. The user clicks an action, the button switches to "Processing...", the server action runs, and the badge should immediately flip to the new status, say Confirmed to Cancelled or Paid to Refunded, while the buttons that no longer apply disappear. All without a reload.

Reality was different. After any action, the button got stuck on "Processing..." and never came back. The badge sat on its old status. The strange part: if I refreshed the page by hand, the new status showed up correctly. So the server had clearly succeeded and the database had already changed. Only one thing failed: the UI never reflected that change live.

The symptom

I reproduced it slowly. Click cancel on a booking. The button text changes to "Processing...". Then nothing. The badge stays Confirmed. The button stays put, stuck. I wait a few seconds, no change. The moment I reload, the badge becomes Cancelled and the cancel button disappears as it should. The pattern held for every action: refund, approve, reject all succeeded on the server but never showed up on screen until a reload.

Tracing it

My first and most reasonable guess: the server action was silently failing. I opened the Network tab, clicked again, and watched the POST. It came back 200, the response body said success, and when I checked the database the status had in fact changed. So the server was not the culprit. The change was real, it just never reached the DOM.

Second guess: the function that updates the UI was never being called. There is a reflectBookingState() whose job is to rewrite the badge and the dataset. I dropped a console.log inside the click handler and at the top of reflectBookingState. Both fired. So the function ran, the arguments arrived, nothing was skipped.

Since the function ran but the screen did not change, I stopped guessing and started printing what it actually held. I logged the target element reflectBookingState received:

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

What printed was not the <article class="booking-card"> I expected. It was <button class="booking-action">. The function was updating the button, not the card. That was the click.

Why closest() matched the button

The key is how Element.closest() actually works. It does not jump straight to the parent. It starts at the element itself, checks whether it matches the selector, and only walks up if it does not. If the element you call it on already matches, it returns that same element.

The problem: the action buttons in this markup also carry data-booking-id, used to send the booking id to the server on click. So both the card and the button match the selector [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>

When the handler calls btn.closest('[data-booking-id]'), btn itself already matches, so closest() hands back the button instead of climbing to the article. Then reflectBookingState does its job on the wrong element:

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 gets set on the button, with no visible effect. Then el.querySelector('.status-badge') looks for a badge inside the button. The button has no .status-badge child, so the result is null, the if (badge) guard saves it from throwing, and nothing changes. The real badge, living inside the article, is never touched. No error, no warning. Just an update that landed on the wrong element.

The fix

The fix is one line: stop selecting the parent through an attribute the child also uses. Select the card by something only the card has, its class.

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

closest('.booking-card') will never stop at the button, because the button is not a .booking-card. It climbs to the article, and reflectBookingState finally touches the right badge. I tested it live: a refund flipped Confirmed to Cancelled and Paid to Refunded, the buttons that no longer applied disappeared, all with no reload.

There is a more structural fix if you want to close the gap for good: put data-booking-id on the card only, not on the button. If the button needs the id, let it read it from the card via btn.closest('.booking-card').dataset.bookingId. One source for the id, no two elements fighting over the same selector.

Takeaways

  • closest() includes the element itself. If the element you call it on already matches the selector, you get that element back, not its ancestor.
  • Do not select a container through an attribute its children also carry. Select it by something unique to the container, like a class only the card has.
  • When an update function runs but the screen does not change, log the element it actually holds, not just whether it was called.
  • A 200 from the server plus a stuck UI usually means the bug is in the client reflect step, not the action itself.
  • Keep the id on a single element and read it from children via closest, rather than duplicating the attribute everywhere.