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 functionalitythis.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! 😰