One of the main value propositions of React is the management of event listeners.
<button onClick={doThing}>...
But with the DOM API, we need to use addEventListener
buttonNode.addEventListener('click', doThing);
This looks like a trivial difference, but more care is required when using addEventListener
.
Leaking with addEventListener
The main problem with addEventListener
is garbage collection. If the DOM node is deleted, listeners do not leak.
const bodyNode = document.body;
bodyNode.insertAdjacentHTML('afterbegin', `
<button>Say hello</button>
<output></output>
`);
const buttonNode = bodyNode.querySelector('button');
const outputNode = bodyNode.querySelector('output');
buttonNode.addEventListener('click', () => {
outputNode.innerText = 'Hello pressed';
});
// Removing the buttonNode cleans up the listeners
buttonNode.remove();
However, one can easily leak listeners for DOM nodes that stick around.
const bodyNode = document.body;
bodyNode.insertAdjacentHTML('afterbegin', `
<button>Say hello</button>
<output></output>
`);
const buttonNode = bodyNode.querySelector('button');
const outputNode = bodyNode.querySelector('output');
buttonNode.addEventListener('click', () => {
outputNode.innerText = 'Hello pressed';
});
outputNode.remove();
// Removing the outputNode and reusing buttonNode later, the listener on buttonNode has now leaked
addEventListener
makes it hard to do the right thing
OK, fine, we need to use removeEventListener
, right?
const bodyNode = document.body;
bodyNode.insertAdjacentHTML('afterbegin', `
<button>Say hello</button>
<output></output>
`);
const buttonNode = bodyNode.querySelector('button');
const outputNode = bodyNode.querySelector('output');
const buttonHandler = () => {
outputNode.innerText = 'Hello pressed';
};
buttonNode.addEventListener('click', buttonHandler);
// Leak begone!
buttonNode.removeEventListener('click', buttonHandler);
outputNode.remove();
But look closer at what we needed to do to the code.
// BEFORE
buttonNode.addEventListener('click', () => {
outputNode.innerText = 'Hello pressed';
});
// AFTER
const buttonHandler = () => {
outputNode.innerText = 'Hello pressed';
};
buttonNode.addEventListener('click', buttonHandler);
Since removeEventListener
requires the original handler, we had to extract it into its own variable. But this makes the usage of addEventListener
harder to read. The code is less fluent.
We can restore the fluency of the code with some alternative APIs.
The DOM API way is to use an AbortSignal
, but that is still too wordy.
Alternative API, clean up function
addEventListener
could have returned a function when called removes the listener. This is how modern APIs such as Firestore and useEffect
tend to do things.
We implement a wrapper as a proof of concept.
const addEventListener = (node, type, listener, params) => {
node.addEventListener(type, listener, params);
return () => {
node.removeEventListener(type, listener, params);
};
};
Then we can use it.
const removeListener = addEventListener(buttonNode, 'click', () => {
outputNode.innerText = 'Hello pressed';
});
//sometime later
removeListener();
Much cleaner and easier to properly clean up listeners.
Can we blame addEventListener
for all our woes? It definitely doesn't have an easy-to-use API, and I would attribute most of the blame to it, but the DOM API still has many other sharp corners.
Do you ensure your listeners are appropriately handled? We're like to hear from you, Battlefy is hiring.