DF[dot]DEV

Challenges with context in JavaScript classes

One of the more difficult things for JavaScript developers to understand is context. Context within JavaScript defines the value of the this keyword and unlike many other languages, it is determined at runtime during the invocation of a function and not when the code is originally written.

Due to this, there is no way to simply look at code and determine what the value of this will be. When I see a this reference within a function, the question I’ll ask myself is “how is this function likely to be called?”. Of course that is overly broad as there are many different ways a function can be called if dealing with third-party code, but within our own code bases we should be able to make safe assumptions about the invocation.

While context in JavaScript can be a broad topic, I wanted to take this time to specifically look at how it affects the way we write our code within classes. I’ll also bring up one of the things I wish was possible — but more on that in a bit.

Let’s start with a basic counter implemented as a custom element (part of the Web Components spec)

class CounterBroken extends HTMLElement {
displayEl = null;
buttonEl = null;
currentCount = 0;
connectedCallback() {
this.innerHTML = `
<p>Current count: <span data-current-count></span></p>
<button type="button" data-increment-button>Increment</button>
`;
this.displayEl = this.querySelector('[data-current-count]');
this.buttonEl = this.querySelector('[data-increment-button]');
this.buttonEl.addEventListener('click', this.updateDisplayCount);
this.updateDisplayCount();
}
updateDisplayCount() {
this.currentCount = this.currentCount + 1;
this.displayEl.innerText = this.currentCount;
}
}

This looks all well and good, but there’s an issue with this code. If you’ve been through the wringer with context before you might notice the problem.

I’ve setup this code on StackBlitz so that we can debug this issue together. After loading the example, click on the “increment” button under “broken count”. Hmm, it didn’t do anything — we should check the console. Aha! Uncaught TypeError: Cannot set properties of undefined (setting 'innerText'). Wait…what? Why is this.displayEl undefined? The updateDisplayCount method works fine at the end of connectedCallback where it sets the counter to 1. What’s going on here?

Let’s dig a bit deeper. If we put a debugger; statement at the top of updateDisplayCount we can get an idea of what’s happening. Refresh the page and the debugger will trigger as updateDisplayCount is called at the end of connectedCallback. Taking a look at the scope everything looks as it should and this is the counter-broken element, as expected.

Resume the script and then click on the broken counter button so that the debugger triggers again. Notice anything different about the this value? Yep, it’s button instead of counter-broken. That’s why we were getting the console error.

Why is this different between the two calls? Shouldn’t it be the same? Nothing changed with the updateDisplayCount method and it’s being called from the same class!

Remember the “how is this function likely to be called?” question from earlier? Let’s also add “who did the calling?” to the question and consider that as we look at what’s happening.

When the first call happens, it’s at the end of the connectedCallback method. While this is a custom element lifecycle method that is called automatically on mount, it is the instance of CounterBroken that you would expect that is doing the calling. Therefore the object assigned to this is the instance of CounterBroken because it was the caller. Access to both displayEl and currentCount work as expected.

When updateDisplayCount is called as a result of the button click event, the context changes because the caller changes. What’s happening is that you’re essentially handing the updateDisplayCount method off to the button element and saying, “when you receive a click event, run this function”. That’s why the caller changes to button and displayEl and currentCount are no longer accessible as they don’t exist on the button object.

How do we fix the problem?

So, what can we do about this? How can we make it so that the updateDisplayCount method works as expected in both places? Ultimately we need to modify the method that is passed to the addEventListener callback so that we can enforce the context that it will reference when the button element calls it.

There are several ways to do this:

Option 1: use bind to return a copy of the function with its context explicitly set

In JavaScript, we can use the bind method on functions to return a copy of the function with it’s context explicitly set. It’s possible to make use of this in the addEventListener call to pass in a callback function that will in turn be called with the context of the CounterFixed class instance.

class CounterFixed extends HTMLElement {
connectedCallback() {
// ...
this.buttonEl.addEventListener(
'click',
this.updateDisplayCount.bind(this)
);
// ...
}
}

Option 2: Treat the callback as a property of the class

Arrow functions in JavaScript weren’t designed to save you keystrokes. Their true purpose is to expose the logic of your function to lexical context. Lexical context means that the value of this will be derived from the surrounding code as seen from the point of view of the lexer/tokenizer component of a JS engine. Lexical context in our example would mean that if we wrote our updateDisplayCount method as an arrow function, the value of this will be the instance of CounterFixed that we expect.

To do this, we change updateDisplayCount from a function declaration to a function expression that is now a property value on each instance. There’s no need to change how it’s referenced in addEventListener since the context of the callback has been set explicitly by use of the arrow function.

class CounterFixed extends HTMLElement {
connectedCallback() {
// ...
this.buttonEl.addEventListener('click', this.updateDisplayCount);
this.updateDisplayCount();
}
updateDisplayCount = () => {
this.currentCount = this.currentCount + 1;
this.displayEl.innerText = this.currentCount;
}
}

Option 3: Wrap the callback in an arrow function inside the addEventListener call

Another option would be to create an arrow function inside the addEventListener call. Just like the option above, lexical context will be applied to all logic within this arrow function and the updateDisplayCount method will have the context of CounterFixed as expected.

class CounterFixed extends HTMLElement {
connectedCallback() {
// ...
this.buttonEl.addEventListener('click', () =>
this.updateDisplayCount()
);
// ...
}
}

What solution should I choose?

The right solution is always going to be a personal choice that aligns with your mental model of how context works within JavaScript classes. It’s also going to depend on whether or not you need to call removeEventListener at any point. If you do, you’ll need a reference to the function and the first (bind) and third options won’t give that to you. The bind example could be updated so that the function returned is saved to a new variable, but that’s not how it’s structured here. Something to note, is that there is new browser functionality that can make use of AbortController to remove event handlers without a reference, making the concern over keeping function references unnecessary.

My personal preference in a codebase targeting modern browsers is for the first option of utilizing bind in an addEventListener call. It communicates the intent clearly and it doesn’t change the callback method to a class property and function expression. If I need to use removeEventListener at any point I’ll lean on the AbortController functionality, which has the added benefit of being able to remove multiple listeners at once.

An annoyance with JavaScript Web APIs

If addEventListener had a way to enforce a context value when invoking the provided callback, it could lead to an easy fix for this issue. Imagine if something like this was an option where a context object could be set in the options parameter:

// NOTE: This is not real functionality
this.buttonEl.addEventListener(
'click',
this.updateDisplayCount,
{ context: this }
);

In each option listed above there is an additional function created that wouldn’t be necessary with an approach like this. The mock functionality above would allow for the updateDisplayCount to remain as a class method and would be referenced on the class prototype instead of being created as as either a property or variable on each instance. Access to the element that an event listener is attached to would still be available through the currentTarget property of the event object passed to the callback function.

I doubt the extraneous functions created would ever become a serious concern involving memory usage, but if you’re focused on making sure your code is as efficient as possible it’s something to be aware of.

As of now this is just me rambling about a gripe I have with addEventListener. I’m sure there is likely a good reason why this functionality doesn’t exist, but I haven’t taken the time yet to dive into the internals of how browsers handle invoking event listener callbacks to find out why.

Conclusion

Understanding context in JavaScript and creating a mental model around it is difficult. When classes were introduced in ES2015/ES6 it felt to me as if the problem was exacerbated due to how the this keyword typically works in other languages and then trying to apply that understanding to JavaScript where it works much differently.

If you’ve been burned by context before, you’re not alone. When React transitioned from using class components to using function components, the difficulty of using context within classes was one of the reasons cited for the change.

For those developers who are struggling with context in JavaScript, I hope this post helps a bit.

Further reading

You Don’t Know JS | Objects & Classes | Chapter 4 - “This Works”

The entire You Don’t Know JS series is a gold mine of information I think every JavaScript developer should read at least once. When you’re confident in your understanding of context, Chapter 5 of Objects & Classes will help serve as a reality check! 😰

Back to posts