BattlefyBlogHistoryOpen menu
Close menuHistory

Just one event listener will do

Ronald Chen September 5th 2022

When rendering a list of clickable things, where should the click handler live? Typically click handlers are found on the child component, but what if there was a better way?

Problem

For our sample problem, we'll have 3 clickable cards and when any card is clicked, a message is shown.

Child onCardClicked

The most straight-forward implementation is for the child component to offer the prop onCardClicked, which is called when the card is clicked.

const App = () => {
  const [output, setOutput] = useState('');
  const cards = ['A', 'B', 'C'];
  const onCardClicked = (card) => setOutput(`clicked on ${card}`);

  return (
    <>
      <main>
        {cards.map((card) => (
          <Child key={card} onCardClicked={onCardClicked} card={card} />
        ))}
      </main>
      <output>{output}</output>
    </>
  );
};


const Child = ({ onCardClicked, card }) => {
  const handler = () => {
    onCardClicked(card);
  };

  return <div onClick={handler}>{card}</div>;
};

That is an awful amount of ceremony. We can do better. We can go higher-order.

Higher-order handler

If all the child component is doing is creating a new handler, we can simply do that in the parent.

const App = () => {
  const [output, setOutput] = useState('');
  const cards = ['A', 'B', 'C'];
  const createHandler = (card) => () => setOutput(`clicked on ${card}`);

  return (
    <>
      <main>
        {cards.map((card) => (
          <div key={card} onClick={createHandler(card)}>
            {card}
          </div>
        ))}
      </main>
      <output>{output}</output>
    </>
  );
};

This is better, but we are still creating an EventListener for each card. It doesn't matter when there are a small number of cards, but EventListeners are not free. Each one takes a little bit of memory.

But if we only had a single EventListener on main, how do we know which card was clicked? This is where event.composedPath() comes into play.

event.composedPath()

In HTML, elements contain each other. When an element is clicked, an event bubbles from the clicked element to its parent recursively until we reach the top-level element.

The order in which the event will be bubbled can be queried by event.composedPath().

We can use event.composedPath() in the EventListener to figure out which child element was clicked. The only problem is event.composedPath() returns native HTMLElements. While in theory we could simply read innerText to see which card was clicked, however the more general solution is to record information as data attributes.

const App = () => {
  const [output, setOutput] = useState('');
  const cards = ['A', 'B', 'C'];
  const handler = (event) => {
    for (const element of event.nativeEvent.composedPath()) {
      if (element.dataset.card) {
        setOutput(`clicked on ${element.dataset.card}`);
        return;
      }
    }
  };

  return (
    <>
      <main onClick={handler}>
        {cards.map((card) => (
          <div key={card} data-card={card}>
            {card}
          </div>
        ))}
      </main>
      <output>{output}</output>
    </>
  );
};

Note that React wraps native Events with their own synthetic events. This is why we use event.nativeEvent.composedPath().

Another neat thing with event.composedPath() is the onClick handler doesn't need to be the immediate parent of the clicked element. This makes this pattern more resilient to change.

Runnable code samples also include a Vanilla JS version.

Do you write robust event handlers? We'd like to hear from you. Battlefy is hiring.

2022

Powered by
BATTLEFY