Event Listener Hygiene in JavaScript
For a recent web project, I got wondering about event listeners. Someone asked me if it was possible for an event listener to be added to its element more than once, causing duplicate work. I’d thought that the event listener APIs in JavaScript could intelligently de-duplicate event listeners, but after a bit of digging I found that it is more complicated than that. In fact, most of the ways that I’ve been adding event listeners in my JavaScript lately can easily cause them to be added more than once.
The MDN docs for addEventListener say:
If the function or object is already in the list of event listeners for this target, the function or object is not added a second time.
This is pretty simple and straight-forward. However, functions take a lot of different forms in JavaScript. Here are a few ways I came up with for adding event listeners:
// 1. A global function () { }
function functionEventListener(event) { }
button.addEventListener("click", functionEventListener);
// 2. A global arrow function
const arrowFunctionEventListener = event => { };
button.addEventListener("click", arrowFunctionEventListener);
// 3. An inline function
button.addEventListener("click", function (event) { });
// 4. An arrow function without braces
button.addEventListener("click", event => handleClickEvent());
// 5. An arrow function with braces
button.addEventListener("click", event => { });
// 6. A simple function with a this binding
button.addEventListener("click", boundEventListener.bind(context));
// 7. An instance method without a this binding
button.addEventListener("click", this.eventListener);
// 8. An instance method with a this binding
button.addEventListener("click", this.eventListener.bind(this));
// 9. An instance method called within an arrow function
button.addEventListener("click", event => this.eventListener(event));
// 10. A instance method with a this binding, made up front and stored
// in an ivar.
button.addEventListener("click", this.#boundEventListener);
I’m sure you can come up with some others, too!
Almost all of these will result in multiple instances of your function being
added to the click event for this button. Let’s go over them.
#Global Functions
A function declared in the global scope with the function keyword, or an arrow
function stored in the global scope, will be correctly de-duped.
function handleClickEvent(event) {
console.log("Click!", event);
}
const handleClickEventArrowFunction = event => {
console.log("Click!", event);
}
// 1. Global function
button.addEventListener("click", handleClickEvent);
// 2. Global arrow function
button.addEventListener("click", handleClickEventArrowFunction);
Referring to global state will always point to the same function object,
assuming they don’t get reassigned or redeclared. const helps with the former,
in the case of arrow functions.
#Inline Functions
Function declared inline to the addEventListener call will be added multiple
times because a new function instance is created each time these lines of code
are run. This applies to function functions and arrow functions.
// 3. Inline `function` function
button.addEventListener("click", function (event) {
console.log("Click!", event);
});
// 4, 5. Inline arrow functions
button.addEventListener("click", (event) => console.log("Click!", event));
button.addEventListener("click", (event) => { console.log("Click!", event); });
// 9. An arrow function calling an instance method
button.addEventListener("click", event => this.eventListener(event));
These kinds of functions are also called “anonymous” functions. And MDN talks
at some length about their quirks vis-à-vis addEventListener.
#Bound Functions
.bind() lets you control the value of this inside a function.
You can also use it to create a new function with some of its arguments bound to
fixed values. This is called “partial application” and is an extremely powerful
feature of functional programming languages.
One of the common ways of encapsulating controller logic for an element in the
DOM inside a class is to use .bind() to set this inside an instance method
to the class instance. I do this kind of thing a lot:
class Controller {
constructor(element) {
this.#element = element;
element.addEventListener("click", this.clickEventListener.bind(this));
}
clickEventListener(event) {
console.log("Click!", event);
}
}
As I noted above, .bind() returns a new function instance. addEventListener
gets a new function that has this inside the function to the instance’s value
of this. So, no de-duping happening here.
#Storing a Bound Function
.bind() returns a new function, so an easy work around is to store the result
of calling it for later.
class Controller {
// 10. Store the result of calling .bind() and use it when you need to
// add an event listener.
#boundClickEventListener;
constructor(element) {
this.#element = element;
this.#boundClickEventListener = this.clickEventListener.bind(this);
}
addClickEventListener() {
this.#element.addEventListener("click", this.#boundClickEventListener);
}
clickEventListener(event) {
console.log("Click!", event);
}
}
No matter how many times controller.addClickEventListener() is called, only a
single listener will be added.
An optimization of this pattern initializes the ivar with an arrow closure. I didn’t even know you could do this until today!
class Controller {
#boundClickEventListener = (event) => {
console.log("Click!", event, this);
};
constructor(element) {
this.#element = element;
}
addClickEventListener() {
this.#element.addEventListener("click", this.#boundClickEventListener);
}
}
While talking about this topic with someone, they quipped that arrow functions
are “sugar around .bind()”. I didn’t really get that at first, but I think
it’s pretty clear with this example. Writing it this way also removes a bit of
boilerplate in the constructor.
Storing the resulting function has the benefit that you can remove the event listener later on if you no longer need the event listener.
#Unbound Instance Method
There’s one left from my list above: number 7. This one adds an instance method,
but doesn’t call .bind() on it.
class Controller {
constructor(element) {
this.#element = element;
element.addEventListener("click", this.clickEventListener);
}
clickEventListener(event) {
console.log("Click!", event);
}
}
This adds clickEventListener as an event listener, and behaves the same as if
you’d passed a Global Function to addEventListener. The
surprising quirk here is that this is bound to the DOM element, per the
addEventListener docs:
When attaching a handler function to an element using
addEventListener(), the value ofthisinside the handler will be a reference to the element. It will be the same as the value of thecurrentTargetproperty of the event argument that is passed to the handler.
You might be surprised using this code if later on the implementation of
clickEventListener needs access to the Controller instance, rather than the
DOM element.
#A Note About capture
All of these examples assume the same capture mode for the event listener.
Listeners where capture is true are attached to a different part of the DOM
tree and don’t get de-duped if they match a listener that was added
with capture set to false. This is whole ’nother level of complexity I
decided to leave out of this.
#The End
All of these approaches work. The event listener is added to the element, and called correctly when the event fires. Each one has quirks that result from the peculiar way that functions behave in JavaScript. When I started exploring this topic, I was surprised how many of these didn’t match my mental model.