Interactive lists, tables, and menus often create child controls after the first page load. Event delegation keeps one listener on a stable parent element so those later child controls can still run the same JavaScript behavior.
Click events bubble from the innermost clicked element toward its ancestors, so a parent listener can inspect event.target and decide whether the click came from a matching child control. Element.closest() is useful when the user clicks text or an icon inside a button, because it walks back up to the button that carries the action attribute.
The parent handler should reject clicks that do not belong to the delegated control and should keep dynamic insertion separate from action handling. That makes the listener safe for nested markup and confirms that newly inserted elements work without registering another listener for each new child.
<div class="actions"> <button id="add-ticket" type="button">Add priority ticket</button> </div> <ul id="ticket-list" class="ticket-list" aria-label="Support tickets"> <li class="ticket" data-ticket-id="billing-update"> <p class="ticket__title">Billing update</p> <button data-action="complete" type="button"> <span>Complete ticket</span> </button> </li> </ul> <p id="ticket-status" role="status">No tickets completed yet.</p>
The id on the parent list gives the listener a stable attachment point. The data-action and data-ticket-id attributes give the handler behavior and item selectors that are separate from visual class names.
.ticket-list { display: grid; gap: 0.85rem; padding: 0; list-style: none; } .ticket { display: grid; gap: 0.75rem; padding: 1rem; border: 1px solid #d8dee8; background: #f8fafc; } .ticket--complete { border-color: #16833a; background: #eefaf1; }
const ticketList = document.querySelector("#ticket-list"); const addTicketButton = document.querySelector("#add-ticket"); const status = document.querySelector("#ticket-status"); if (!ticketList || !addTicketButton || !status) { throw new Error("Ticket queue markup is missing."); }
Guard the required elements before adding listeners so selector or markup drift fails at startup instead of inside a later click handler.
Related: Select DOM elements with JavaScript
ticketList.addEventListener("click", (event) => { const completeButton = event.target instanceof Element ? event.target.closest("[data-action='complete']") : null; if (!completeButton || !ticketList.contains(completeButton)) { return; } const ticket = completeButton.closest("[data-ticket-id]"); if (!ticket) { return; } ticket.classList.add("ticket--complete"); completeButton.disabled = true; completeButton.textContent = "Completed"; status.textContent = `${ticket.dataset.ticketId} completed.`; });
event.target may be the nested span inside the button. closest() finds the button that owns the delegated action, and contains() keeps the handler scoped to the parent list.
Related: Add a JavaScript event listener
addTicketButton.addEventListener("click", () => { if (document.querySelector("[data-ticket-id='priority-shipping']")) { return; } ticketList.insertAdjacentHTML( "beforeend", `<li class="ticket" data-ticket-id="priority-shipping"> <p class="ticket__title">Priority shipping</p> <button data-action="complete" type="button"> <span>Complete ticket</span> </button> </li>`, ); status.textContent = "priority-shipping added."; });
The new button is added after the parent listener already exists. No extra listener is registered for the inserted li or its button.
<link rel="stylesheet" href="styles.css"> <script src="tickets.js" defer></script>
defer lets the browser parse the list and buttons before tickets.js runs its selectors.
Related: Load JavaScript with defer
> document.querySelector("[data-ticket-id='priority-shipping']") !== null
true
> document.querySelector("[data-ticket-id='priority-shipping']").classList.contains("ticket--complete")
true
> document.querySelector("#ticket-status").textContent
"priority-shipping completed."