JavaScript Event Delegation for Dynamic Buttons

Practical advice from AriZone

In a learning catalogue, a “Mark” button works when the page first opens, but new cards added later ignore clicks. Reloading provides temporary relief. The cause may have nothing to do with the network: handlers were attached only to buttons that existed when the script ran. JavaScript event delegation moves the handling to a stable container and lets you work with elements that appear later.

This example makes no requests, changes no orders and stores no personal information. It demonstrates the mechanism on a local HTML page. A real shop also needs server-side permission checks, error handling and protection against repeated operations. Reproduce the behaviour separately from your production website first, so you do not confuse an event problem with the cart or a third-party module.

Where the connection between button and handler disappears

A common initial approach is to find buttons with querySelectorAll and attach a click listener to each one. That list does not automatically gain new nodes when a request or filtering adds them. A new element can look identical to an old one, but appearance says nothing about whether a handler exists. Check when the node is created and when the listener is registered.

A click event normally bubbles from the clicked element through its ancestors. One listener on a list can therefore receive clicks from nested buttons. event.target is the original target, while event.currentTarget is the element on which the current listener was registered. This distinction matters: clicking text or an icon inside a button does not necessarily make the button itself the target.

Do not begin the repair by intercepting every click globally on document. Choose the nearest stable container for the component you need. This makes its responsibility clearer and helps avoid accidentally responding to an unrelated form. If filtering replaces the entire container, delegation on the old node will not help: either preserve the container or manage listener registration within the component's lifecycle.

A minimal page without additional libraries

Create a new delegation-demo.html file in a test directory and open it in a modern browser. No administrator permissions, packages or server are needed. The identifiers catalog, add-demo and item-1 are examples: replace them consistently in both markup and script for your project. Do not insert this code directly into a production theme editor; a separate file is easy to remove after checking it.

Start with the following markup. It uses actual buttons rather than clickable div elements: a standard button supports keyboard activation. type="button" prevents an accidental form submission if the component later ends up inside a form. The aria-pressed attribute communicates the toggle's state to assistive technology. Text inside a span deliberately provides a nested target to test.

<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Delegation demo</title>
<body>
  <button id="add-demo" type="button">Add card</button>
  <div id="catalog">
    <button type="button" data-action="toggle"
            data-item-id="item-1" aria-pressed="false">
      <span>Mark</span>
    </button>
  </div>
</body>
</html>

Add the following script after the markup, before the closing body tag. The catalogue will then exist when the query runs. The handler finds the nearest matching button with closest, checks that it belongs to the container and changes only its state. New text is assigned with textContent: this learning scenario does not require interpreting arbitrary HTML. The instanceof Element check protects the closest call from an unsuitable target type.

const catalog = document.querySelector('#catalog');

catalog.addEventListener('click', (event) => {
  if (!(event.target instanceof Element)) return;
  const button = event.target.closest(
    'button[data-action="toggle"]'
  );
  if (!button || !catalog.contains(button)) return;
  if (button.disabled) return;

  const pressed = button.getAttribute('aria-pressed') === 'true';
  button.setAttribute('aria-pressed', String(!pressed));
  button.textContent = pressed ? 'Mark' : 'Marked';
});

Clicking should change aria-pressed and the button label; no other card should toggle. This is the example's expected behaviour, not a measurement from your website. The closest method checks the element itself and moves up through its ancestors, so clicking the nested span also finds the button. MDN documentation explains event bubbling and the behaviour of closest.

Add a new element after initialisation

Place the next snippet in the same script after registering the listener. The “Add card” button creates another button without a separate click listener. There is no external data here: the example supplies its own identifier and text. For API data, do not treat a received string as ready-to-use HTML markup; create nodes and populate text properties or apply appropriate, verified handling.

let nextItem = 2;
document.querySelector('#add-demo').addEventListener('click', () => {
  const button = document.createElement('button');
  button.type = 'button';
  button.dataset.action = 'toggle';
  button.dataset.itemId = `item-${nextItem++}`;
  button.setAttribute('aria-pressed', 'false');
  button.textContent = 'Mark';
  catalog.append(button);
});

The test order matters. Activate the initial button first, then add a new card and click it. Next, add several more and toggle only one of them. Finally, click an empty area of the catalogue: nothing should change. Repeat the check with Tab and Space or Enter. If the mouse works but the keyboard does not, check whether the standard button has been replaced with different markup.

Limitations to check before release

Delegation does not fix business-logic errors. If a click initiates payment or deletion, separate server-side guarantees and confirmation where appropriate are necessary. Do not trust data-item-id as proof of a user's permissions: the attribute can be changed in the browser. Our example only toggles local state; it does not implement saving a product to a user's account.

Not all events bubble in the same way. Check the documentation before choosing a different event type; mechanically replacing click with focus can produce different behaviour. Also determine whether third-party code calls stopPropagation before the event reaches the container. Do not remove that call blindly: it may protect another component against an unwanted response. Shadow DOM requires separate consideration of the event path and retargeting.

Before publishing, check repeated initialisation: one click must not trigger two identical handlers. Keep the previous script version in Git and review your changes before pushing. To roll back, restore the previous implementation rather than disabling all JavaScript on the website. It is useful to record the new-card test as a regression scenario for the next catalogue update.

Conclusion: a stable container, a checked target and a small set of tests make dynamic buttons' behaviour understandable. If interaction still breaks after a theme or module update, AriZone can help separate the event problem from requests and verify the fix on a test version of the website.

Discussion

Leave a comment

Add a comment

Your email will not be published. Required fields are marked *.

Cloudflare Turnstile spam protection