March 06, 2019
I spent hours digging into the details of event handlers this week, namely how best to initialize and destroy them within class methods.
In the process, a conversation erupted on Twitter related to my problem.
The tweet that started it all:
So... you need a "hard this-bound" method to pass in, like:
— getify (@getify) March 4, 2019
btn.addEventListener("click",x.someMethod.bind(x));
Or... you can define an arrow function wrapper:
btn.addEventListneer("click",() => x.someMethod());
The entire thread is worth reading (you can read it by clicking the tweet above). In short, it describes the two common techniques for hard-binding class methods, and the issues that arise as a result.
Option 1: using .bind(this):
class Bar extends HTMLElement {
constructor() {
super()
// here's the manual bind
this.boundHandleClick = this.handleClick.bind(this)
}
connectedCallback() {
this.addEventListener('click', this.boundHandleClick)
}
handleClick(e) {
console.log(`I was clicked: ${e}`)
}
}
Option 2: using an arrow function:
class Baz extends HTMLElement {
constructor() {
super()
// here's the lexical bind
this.boundHandleClick = e => console.log(`I was clicked: ${e}`)
}
connectedCallback() {
this.addEventListener('click', this.boundHandleClick)
}
}
All this chatter begs the question: Why is binding necessary anyway? Did the creators of JavaScript miss something?
The answer, like everything in development, is complicated.
At the root of the issue is the question of what this actually refers to in JavaScript.
Per the MDN documentation:
the ‘this’ keyword refers to the context object in which the current code is executed.
Let’s dig deeper with an example. If this is being referenced outside of an object, like so:
console.log(this)
… it’ll be equal to either window or undefined, depending on whether you’re running in strict mode (undefined in strict mode).
The value of this changes when it’s referenced within a context object. To demonstrate, here’s a simple example of this use in a class:
class Dog {
sound = 'woof'
bark() {
console.log(this.sound)
}
}
let puppy = new Dog()
puppy.bark() // woof
In this example, this refers to the class instance, puppy, and its sound property.
But what if, in our offensively contrived example, we wanted our puppy to bark whenever someone knocked on the door?
let door = document.createElement('p')
let content = document.createTextNode('🚪')
door.appendChild(content)
document.body.appendChild(door)
door.addEventListener('knock', puppy.bark)
door.dispatchEvent(new CustomEvent('knock'), {}) // undefined
Now, when we pass our class method to the event handler, its context was lost 😱.
This is because the addEventListener function accepts the method as its callback and executes it on its own terms, in the global object implementing the Web API. Said another way, we’re really passing a reference to the function, and the context changes because the call site is different.
Which brings us back to the techniques mentioned earlier for resolving this issue.
To solve this problem, we have two options: using .bind(this) or an arrow function.
Option One: .bind(this):
class Dog {
sound = 'woof'
boundBark = this.bark.bind(this)
bark() {
console.log(this.sound)
}
}
let puppy = new Dog()
let door = document.createElement('p')
let content = document.createTextNode('🚪')
door.appendChild(content)
document.body.appendChild(door)
door.addEventListener('knock', puppy.boundBark)
door.dispatchEvent(new CustomEvent('knock'), {}) // 'woof'
With our bound function passed, we hear our familiar sound again.
To explain how this works, we once again return to the MDN documentation:
The .bind() method creates a new function that, when called, has its this keyword set to the provided value.
When we bound our this to the bark method and passed it to the event handler, it was guaranteeing that we’d still be referring to Dog when the method was called later.
Option Two: arrow function:
class Dog {
sound = 'woof'
boundBark = () => this.bark()
bark() {
console.log(this.sound)
}
}
let puppy = new Dog()
let door = document.createElement('p')
let content = document.createTextNode('🚪')
door.appendChild(content)
document.body.appendChild(door)
door.addEventListener('knock', puppy.boundBark)
door.dispatchEvent(new CustomEvent('knock'), {}) // 'woof'
With the advent of arrow functions, retaining context in class methods became much easier. Because the functions have no this context, the value is derived from the enclosing execution context, which is our Dog class.
Some may be wondering why we wouldn’t just perform the bind in the event listener itself, like so:
// ...
door.addEventListener('knock', puppy.bark.bind(this))
// ...
Because the above is not a named function, removeEventListener won’t be able to properly match with its corresponding addEventListener declaration, and you’ll end up attaching multiple events without ever properly collecting your garbage (worst guest ever).
In order for removeEventListener to destroy the events previously added, it requires:
Now that we have two techniques for solving this problem, it’s time to pick one to implement.
The truth is, the question is a matter of preference.
What matters is that you understand why you’re binding at all, which problem it’s solving, and the alternative approaches to weigh among your team.
Then again, there’s a growing sense that arrow functions as class methods have their drawbacks (namely because they’re not methods at all, but rather converted to class properties).
Regardless of whichever method you pick, neither option will include the bound function in the class prototype. That means the functions are duplicated with each new instance created.
As Kyle concluded in his tweet thread, hard-bounded functions are “fundamentally incompatible with a prototypal-class system”.
If possible, avoid referencing this in methods added to event listeners. Otherwise, understand that maintaining context for events comes with a cost, so do so thoughtfully.
If you’d like to read more into this and arrow functions or using .bind(this), the following resources might be helpful:
As always, thanks for reading!
a blog about javascript, node, and math musings.
twitter plug