Why does my SSR page lose the click handler after hydration on the edge?

Hey everyone, I’m wiring up a Next.js-style SSR page that’s rendered at the edge, and I’m trying to keep a tiny client bundle, but I’m hitting a weird failure mode where the UI looks right yet clicks sometimes do nothing after navigation.

// server renders this markup
// <button id="buy" data-sku="123">Buy</button>

export function attachBuyHandler() {
  const btn = document.querySelector('#buy');
  if (!btn) return;

  btn.addEventListener('click', (e) => {
    const sku = e.currentTarget.dataset.sku;
    window.dispatchEvent(new CustomEvent('add-to-cart', { detail: { sku } }));
  });
}

// called from a small client entry
attachBuyHandler();

If the server HTML is replaced during hydration or streaming, what’s the most reliable pattern to attach events (without shipping the whole component) so I don’t end up with “dead” buttons?

1 Like

look — you’re binding to a node that hydration can throw away. when that button gets replaced, the listener dies with it.

put the listener on something that survives, like document or a stable wrapper, and use closest() to find the button. capture can help because some hydration code gets cute and stops the event before it bubbles up.

export function attachBuyHandler() {
  document.addEventListener(
    'click',
    (e) => {
      const btn = e.target.closest('#buy');
      if (!btn) return;

      const sku = btn.dataset.sku;
      window.dispatchEvent(new CustomEvent('add-to-cart', { detail: { sku } }));
    },
    true
  );
}

if you can avoid #buy and use a data attribute instead, that’s cleaner too.