BattlefyBlogHistoryOpen menu
Close menuHistory

Is this why the DOM API doesn't scale?

Ronald Chen January 16th 2023

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.

2024

  • MLBB Custom Lobby Feature
    Stefan Wilson - April 16th 2024

2023

2022

Powered by
BATTLEFY