aleph node

aleph node

Considerations When Passing Class Methods to Event Handlers

March 06, 2019

chain
Photo by Stephen Hickman on Unsplash

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:

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.

Why is this Necessary?

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.

Two Solutions, in Detail

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.

Why the boundBark Named Function?

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:

  • the type,
  • the listener (need a name!), and
  • same capture/useCapture flag.

Now that we have two techniques for solving this problem, it’s time to pick one to implement.

Which Should I Use?

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.

Further Resources

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!


alephnode

a blog about javascript, node, and math musings.
twitter plug

Join the Newsletter

Subscribe to get my latest content by email.

No spam. Unsubscribe at any time.