Event Delegation
What and Why
Event delegation allows you to add a single event listener to a parent element,
rather than adding event listeners to each child elements. This is useful when
the child elements (e.g. a list of buttons) change or there are lots of them. It
can also be a bit more performant, and frameworks like
React use it for all click
events with the onClick
prop.
How
Event delegation exploits a behaviour of the DOM's event system - event
bubbling. When an event (e.g. a click
event) happens, your browser drills
down to the smallest element affected by the event (e.g. clicked on) - the
event's target
. The browser then bubbles the event up
the DOM tree, firing an event on every parent!
Example
Let's try it out on this UI which should alert
with some information when any
of the buttons is clicked.
<div id="elements-list">
<button>
<img src="/water-tribe.png" />
Water
</button>
<button>
<img src="/earth-kingdom.png" />
Earth
</button>
<button>
<img src="/fire-nation.png" />
Fire
</button>
<button>
<img src="/air-temple.png" />
Air
</button>
</div>
Adding an Event Listener
The default way to do this would be to select all of the buttons, and add an
event listener to each of them. However, since we're using event delegation,
we'll add a 'click'
event listener to the parent:
const list = document.getElementById('elements-list')!
list.addEventListener('click', event => {
// this is where we'll handle the buttons being clicked
})
const list = document.getElementById('elements-list')
list.addEventListener('click', event => {
// this is where we'll handle the buttons being clicked
})
Getting the Clicked Button
Okay, so we've added the event listener how do we get the actual button that was clicked? The event fired when an action occurs has two properties that can help us:
event.currentTarget
- This is the element that we added the event to. In this case, it's the surrounding<div>
event.target
- This is the deepest element that was clicked.
Looking at the two options above, it seems like event.target
is perfect, but
in the example above we have a little issue. If a user clicks a button, but
their mouse is over the image, event.target
will be the <img>
! Obviously, we
could just remove the icons, but they'll look nice and that's important to me.
Instead, we'll use the
closest
method available on all HTML elements. It accepts a CSS selector (just like
querySelector
) and walks up the DOM tree until it finds an element that
matches the selector, or runs out of elements. This allows us to the get the
button that was clicked even if the deepest clicked element is something else!
list.addEventListener('click', event => {
const clickedButton = (event.target as Element).closest('button')
// Check in case there isn't a parent matching the selector
if (!clickedButton) {
return
}
})
list.addEventListener('click', event => {
const clickedButton = event.target.closest('button')
// Check in case there isn't a parent matching the selector
if (!clickedButton) {
return
}
})
Info
Notice above that we return early if event.target.closest
returns a falsy
value. We do this since:
- If a user clicks a part of the containing element (in this case the
surrounding
<div>
), and their mouse isn't also over a button (e.g. if there's some spacing between the buttons), it will return `null``. - TypeScript only: The return type of
event.target.closest
is defined similar toHTMLElement | null
, so if the return value is unchecked you will have type errors (and lose autocomplete) when using it.
Showing the alert
We can finally show the alert, but we have a little issue. We have the button that was clicked, but how do we know which specific button was clicked? We need a way to distinguish them from each other. In this example, we'll use data attributes since their values are easy to access in JavaScript, and there's a no chance for conflicts with any attributes added to browsers in the future.
You can use any data attribute, but since the buttons in this example are for
bending elements from
Avatar: The Last Airbender
I'll use data-element
.
<button data-element="water">
<!-- water button contents -->
</button>
<button data-element="earth">
<!-- earth button contents -->
</button>
<button data-element="fire">
<!-- fire button contents -->
</button>
<button data-element="air">
<!-- air button contents -->
</button>
Now, we can access the value of the data-element
attribute using the dataset
property on every element:
const elementForClickedButton = clickedButton.dataset.element
const elementForClickedButton = clickedButton.dataset.element
Putting it all together
Putting all the above steps together we have our JavaScript
const list = document.getElementById('elements-list')!
list.addEventListener('click', event => {
const clickedButton = event.target.closest('button')
// Check in case there isn't a parent matching the selector
if (!clickedButton) {
return
}
// Get the value from the `data-element` attribute
const bendingElementForClickedButton = clickedButton.dataset.element
showAlertForElement(bendingElementForClickedButton)
})
const list = document.getElementById('elements-list')
list.addEventListener('click', event => {
const clickedButton = event.target.closest('button')
// Check in case there isn't a parent matching the selector
if (!clickedButton) {
return
}
// Get the value from the `data-element` attribute
const bendingElementForClickedButton = clickedButton.dataset.element
showAlertForElement(bendingElementForClickedButton)
})
and our HTML (now with alt attributes!)
<div id="elements-list">
<button data-element="water">
<img
src="/water-tribe.png"
alt="line art, crashing waves inside a circle representing water bending"
/>
Water
</button>
<button data-element="earth">
<img
src="/earth-kingdom.png"
alt="line art, rock-like trapezium with swirl inside representing earth bending"
/>
Earth
</button>
<button data-element="fire">
<img
src="/fire-nation.png"
alt="line art, three-pronged flamed with a swirl in the centre representing fire bending"
/>
Fire
</button>
<button data-element="air">
<img
src="/air-temple.png"
alt="line art, three swirls organised in an upside-down triangle representing airbending"
/>
Air
</button>
</div>
Demo
Here's a demo with the icons, and a complete, useful and 100% non-trivial
showAlertForElement
implementation: