JavaScript being a garbage-collected language, we don't usually have to concern ourselves with questions of allocating and releasing objects. But occasionally, specially when dealing with callbacks, it is easy to keep objects alive indefinitely, even if there are no longer any meaningful references to this.
There's several tools the language provides us to deal with these situations:
-
WeakRef
to store a single weak reference to an object -
WeakMap
to associate values with objects only as long as they exist -
WeakSet
to remember objects as long as they exist -
FinalizationRegistry
to do something when an object gets collected
Depending on the situation, one may need one or another of these features, but the case I want to describe today will make use of the first and the last.
A common case is for objects to care about certain external state changes for as long as they exist. For example, a custom element might want to listen for "scroll"
events on the window
object. But naively adding an event listener to window
means to keep a reference to the object. If these custom elements are short-lived but many in numbers, then they will accumulate in memory, and the additional event listeners will also pile up and waste processing power.
Here's a simple example of something like this:
class MyElement extends HTMLElement {
constructor() {
super()
window.addEventListener("scroll", event => {
this.handleScroll()
})
}
handleScroll() {
this.classList.toggle("top", window.scrollY == 0)
}
}
What we want is to remove the event listener by the time the object gets garbage-collected. To achieve this, we can make use of two features:
Firstly, replacing the strong reference to this
in the event listener with a WeakRef
will prevent the event listener from keeping the object alive if no other references to it exist. Once the object has been collected, the deref()
method will just return undefined.
const ref = new WeakRef(this)
window.addEventListener("scroll", event => {
ref.deref()?.handleScroll()
})
This will allow the object to be garbage-collected, but will keep the event listener attached, meaning it will still fire on every scroll event, fail to deref
the reference and therefore do nothing.
An easy way to clean up event listeners is to combine AbortController
with FinalizationRegistry
.
The former lets us pass a signal to an event that will remove the event, while the latter allows us to run some code when certain objects get collected.
The interface for this is relatively basic: We create a new FinalizationRegistry
and pass it a callback. Then we register an object A and an associated (different) object B. When A gets garbage-collected, it obviously can't be passed to the callback, so instead, the callback is passed B.
const abortRegistry =
new FinalizationRegistry(c => c.abort())
This abortRegistry
now allows us to register an object and an associated AbortController
, and will call abort()
on the controller whenever the object gets collected.
Now we just need to register our object on creation, and pass the controller's signal to the event listener.
Here's the complete code:
const abortRegistry =
new FinalizationRegistry(c => c.abort())
class MyElement extends HTMLElement {
constructor() {
super()
const ref =
new WeakRef(this)
const controller =
new AbortController()
abortRegistry.register(this, controller)
window.addEventListener("scroll", event => {
ref.deref()?.handleScroll()
}, { signal: controller.signal })
}
handleScroll() {
this.classList.toggle("top", window.scrollY == 0)
}
}
Comparing to lifecycle hooks
An easy point of criticism against the example above is that, whenever possible, resource cleanup should be achieved via the custom element API's lifecycle hooks connectedCallback()
and disconnectedCallback()
.
Two reasons why this may not be a viable alternative are that
This kind of cleanup might be necessary for objects that aren't DOM Elements and therefore cannot rely on their DOM connection for cleanup.
There may be cases where it is necessary to continue sending signals to a custom element even while it is disconnected from the DOM, usually because it might be inserted again at a later point in time.
In either case, the only option for cleanup is keep the object set up until it actually becomes inaccessible and therefore couldn't possibly be needed anymore.
And that's it 😁
Did you learn something new? Was this old news to you? Let me know with a comment, and feel free to share how you handle cases like these in practice 👍
Cover Image: Jilbert Ebrahimi (Unsplash License)