The Thinking Behind Simplifying Event Handlers

This article was originally written here, then re-edited and posted to CSS Tricks. The original version has been left here, but you may prefer to read the more polished version at CSS Tricks.


Events are an important part of any interactive web app. Events are used to respond when a user clicks somewhere, focuses on a link with their keyboard, and changes the text in a form. When I first started learning Javascript, I wrote complicated event listeners. More recently I’ve learned how to reduce both the amount of code I write and the number of listeners I need.

Let’s start with a simple example - a navigation element with a few links. We want to print a message to the console showing where a hyperlink goes when the user focuses on it.

<nav>
    <a href="#first">​First link.​</a>
    <a href="#second">Second link.</a>
    <a href="#third">Third link.</a>
</nav>

The intuitive way

When I first started learning about Javascript events, I wrote separate event listener functions for each element. I see this as a common pattern because it’s the simplest way to start - we want specific behavior for each link, so we can use specific code for each.

document.querySelector('a[href="#first"]').addEventListener('focusin', evt => {
    console.log('#first');
});

document.querySelector('a[href="#second"]').addEventListener('focusin', evt => {
    console.log('#second');
});

document.querySelector('a[href="#third"]').addEventListener('focusin', evt => {
    console.log('#third');
});

Reducing duplicate code

The above event listeners are all very similar. Each function prints some text. This duplicate code can be collapsed into a helper function.

function print(text) {
    console.log(text);
}

document
    .querySelector('a[href="#first"]')
    .addEventListener('focusin', evt => print('#first'));
document
    .querySelector('a[href="#second"]')
    .addEventListener('focusin', evt => print('#second'));
document
    .querySelector('a[href="#third"]')
    .addEventListener('focusin', evt => print('#third'));

This is much cleaner, but we still need many functions and event listeners.

Taking advantage of the Event object

The key to simplifying your listeners is the Event object. When an event listener is called, it also sends an Event object as the first argument. This object has some data to describe the event that occurred, such as the time the event happened. To simplify our code, we can use the evt.currentTarget property. currentTarget refers to the element that the event listener is attached to. In our example, it will be one of the 3 links.

const print = evt => {
    const text = evt.currentTarget.href;
    console.log(text);
};

document.querySelector('a[href="#first"]').addEventListener('focusin', print);
document.querySelector('a[href="#second"]').addEventListener('focusin', print);
document.querySelector('a[href="#third"]').addEventListener('focusin', print);

Now there is only 1 function instead of 4. We can re-use the exact same function as an event listener and evt.currentTarget.href will have a different value depending on the element that fired the event.

Using bubbling

One final change can be made to reduce the number of lines in our code. Rather than attaching an event listener to each link, we can just attach a single event listener to the <nav> element that contains all the links.

When an event is fired, it starts off at the element where the event originated (one of the links). However, it won’t stop there. The browser goes to each parent of that link, calling any event listeners on those parents. This will continue until the root of the document is reached (the <body> tag in HTML). This process is called “bubbling”, as the event rises through the document tree like a bubble.

Animation of event bubbling

By attaching an event listener to the list, the focus event will bubble from the link that was focused up to the parent list. We can also take advantage of the evt.target property, which contains the element that fired the event (one of the links) rather than the element that the event listener is attached to (the <nav> element).

const print = evt => {
    const text = evt.target.href;
    console.log(text);
};

document.querySelector('nav').addEventListener('focusin', print);

Now the many event listeners have been collapsed to just one! With more complicated code, the effect will be greater. By utilizing the Event object and bubbling, you can master Javascript events and simplify your event handler code.


What about click events?

evt.target works great with events like focusin and change, where there are only a small number of elements that can receive focus or have input changed.

However, usually you want to listen for click events so you can respond to a user clicking on a button in your application. click events fire for any element in your document, from large <div>s to small <span>s.

As a reminder, we’re making a navigation element with a few interactive links. Let’s make a variant where parts of the links are bold.

<nav>
    <a href="#first"><strong>First</strong> link.</a>
    <a href="#second"><strong>Second</strong> link.</a>
    <a href="#third"><strong>Third</strong> link.</a>
</nav>

Let’s change our Javascript code from before to respond to clicks rather than keyboard focus.

const print = evt => {
    const text = evt.target.href;
    console.log(text);
};

document.querySelector('nav').addEventListener('click', print);

When you test this code, you may notice that when you click on a link, undefined is printed instead of a #first, #second or #third.

The reason that it doesn’t work is that each link contains a <strong> element which can be clicked instead of the <a> element. Since <strong> is not a link, the evt.target.href property is undefined.

We only care about the link elements for our code. If we click somewhere inside a link element, we need to find the parent link. We can use element.closest() to find the parent closest to the clicked element.

const print = evt => {
    let element = evt.target.closest('a');
    if (element != null) {
        const text = element.href;
        console.log(text);
    }
};

Now we can use a single listener for click events! If element.closest() returns null, that means the user clicked somewhere outside of a link and we should ignore the event.


Extra examples addendum

Here are some additional examples to demonstrate how to take advantage of a single event listener.

Lists

A common pattern is to have a list of items that can be interacted with, where new items are inserted dynamically with Javascript. If you have event listeners attached to each item, then your code has to deal with event listeners every time you generate a new element.

<div id="buttons-container"></div>

<button id="add">Add new button</button>
let buttonCounter = 0;
document.getElementById('add').addEventListener('click', evt => {
    const newButton = document.createElement('button');
    newButton.dataset.number = buttonCounter;
    // Make a new event listener every time "Add new button" is clicked
    newButton.addEventListener('click', evt => {
        // When clicked, log the clicked button's number.
        console.log(`Clicked button #${newButton.dataset.number}`);
    });
    buttonCounter++;

    const container = document.getElementById('buttons-container');
    container.appendChild(newButton);
});

By taking advantage of bubbling, you can just have a single event listener on the container. If you create many elements in your app, this reduces the number of listeners from n to 2.

let buttonCounter = 0;
document.getElementById('add').addEventListener('click', evt => {
    const newButton = document.createElement('button');
    newButton.dataset.number = buttonCounter;
    buttonCounter++;

    const container = document.getElementById('buttons-container');
    container.appendChild(newButton);
});
document.getElementById('buttons-container').addEventListener('click', evt => {
    const clickedButton = evt.target.closest('button');
    if (clickedButton != null) {
        // When clicked, log the clicked button's number.
        console.log(`Clicked button #${clickedButton.dataset.number}`);
    }
});

Forms

Perhaps you have a form with lots of inputs, and you want to collect all the user responses into a single object.

<form>
    <label>Name: <input name="name" type="text"/></label>
    <label>Email: <input name="email" type="email"/></label>
    <label>Password: <input name="password" type="password"/></label>
</form>
let responses = {
    name: '',
    email: '',
    password: ''
};

document
	.querySelector('input[name="name"]')
	.addEventListener('change', evt => {
		const inputElement = document.querySelector('input[name="name"]');
		responses.name = inputElement.value;
	});
document
    .querySelector('input[name="email"]')
    .addEventListener('change', evt => {
        const inputElement = document.querySelector('input[name="email"]');
        responses.email = inputElement.value;
    });
document
    .querySelector('input[name="password"]')
    .addEventListener('change', evt => {
        const inputElement = document.querySelector('input[name="password"]');
        responses.password = inputElement.value;
    });

Let’s switch to a single listener on the parent <form> element instead.

let responses = {
    name: '',
    email: '',
    password: ''
};

document.querySelector('form').addEventListener('change', evt => {
    responses[evt.target.name] = evt.target.value;
});