Skip to main content
ANVISoftware Solutions
Lesson 13 of 20Beginner17 min

Events

By the end of this lesson

Respond to user interaction, including delegation and cleanup.

An event is the browser telling you something happened: a click, a key press, a form submission, a field losing focus. You register interest in a particular event on a particular element, and the browser calls your function when it occurs.

That is the whole mechanism. The parts that need care are which element to listen on, and remembering to stop listening.

Handling a form submission
JavaScript
const form = document.querySelector("#expense-form");

form.addEventListener("submit", (event) => {
  event.preventDefault();

  const fields = new FormData(form);
  const amount = Number(fields.get("amount"));

  if (!Number.isFinite(amount) || amount <= 0) {
    showError("Enter an amount greater than zero.");
    return;
  }

  addExpense({
    description: fields.get("description"),
    amount,
  });

  form.reset();
});
  • addEventListener takes the event name and a function to call. The name is a plain string and is case-sensitive: "submit", not "onSubmit".
  • The function receives an event object describing what happened. Here it is used for one thing, and there is more on it below.
  • preventDefault stops the browser's built-in response. For a submit event that response is sending the form and loading a new page, which is not what you want when the page is updating itself.
  • Listening for submit rather than for a click on the button is deliberate. submit also fires when someone presses Enter in a text field, so the keyboard path works with no extra code.
  • FormData reads the fields by their name attribute — the same names from the forms lesson, which is why those names matter.
  • Returning early after showing an error keeps the failure path short and obvious.

Events travel upwards. When you click a button inside a row inside a list, the event does not only happen at the button. After the button, the browser offers it to the row, then the list, then their ancestors, up to the document. This is called bubbling.

Two useful consequences. A listener on an ancestor sees events from everything inside it. And the event object tells you both where you are listening (event.currentTarget) and what was actually clicked (event.target).

That is what makes the next pattern possible, and it is the one worth learning properly.

One listener for a list that keeps changing
JavaScript
const list = document.querySelector("#expense-list");

list.addEventListener("click", (event) => {
  const approveButton = event.target.closest("button[data-action='approve']");

  if (!approveButton) {
    return;
  }

  const row = approveButton.closest(".expense-row");
  approveExpense(row.dataset.expenseId);
});
  • The listener is on the list, not on the buttons. One listener, however many rows there are.
  • event.target is the deepest element the click landed on, which might be an icon inside the button rather than the button itself.
  • closest walks up from there looking for the first ancestor that matches the selector, including the element itself. So a click on the icon still finds the button.
  • When closest finds nothing, the click was somewhere else in the list and there is nothing to do. Guarding first keeps the rest of the handler simple.
  • The row's id comes from its data attribute, so the handler works for any row without needing a reference to it.
  • The important part: rows added, removed or re-rendered later are covered automatically, because the listener was never on a row. Attaching a listener per button means re-attaching after every render, and forgetting once produces buttons that look fine and do nothing.

Removing listeners, and why it is worth the attention:

  • removeEventListener needs the same function reference you registered. A fresh inline arrow is a different function, so it removes nothing
  • Name the handler, or keep it in a variable, when you know it will need removing
  • Pass { once: true } for a listener that should fire one time and then detach itself
  • Give an AbortController's signal to several listeners and call abort() once to remove them all — the tidiest option when a component is torn down
  • A listener on an element you remove from the page goes with it. A listener on document or window does not, and it keeps the handler and everything it references in memory
  • That is the leak to watch for: a panel opened and closed twenty times leaves twenty document listeners, all still running on every key press

Summary

  • addEventListener connects an event name to a function, which receives an event object
  • preventDefault replaces a specific built-in behaviour — know which one you are replacing
  • Events bubble, so one listener on a container plus closest can serve a list that keeps changing
  • Real buttons give keyboard support for free; extra key handling is for shortcuts such as Escape
  • Remove listeners with the same function reference, { once: true }, or an AbortController signal

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Delegate a list with two actions

An expense list has Approve and Reject buttons on every row, and rows are re-rendered whenever the filter changes. Write a single listener that handles both actions and keeps working after a re-render.

Then add Escape to close the confirmation panel, and make sure the panel can be operated with the keyboard alone.

Show solution

One listener on the container handles both actions by reading the action from a data attribute. The alternative — a listener per button — has to be re-run after every render, and the bug when you forget is silent.

Reading the action from data-action rather than from a class keeps the styling and the behaviour independent. Renaming a class then cannot break the handler.

The Escape handler lives on document because the key press can happen anywhere. That is exactly the listener that needs removing when the panel closes, so it is registered with a signal and aborted on close.

Because the buttons are button elements, no key handling is needed for the actions themselves. Enter and Space already work.

JavaScript
const list = document.querySelector("#expense-list");

list.addEventListener("click", (event) => {
  const actionButton = event.target.closest("button[data-action]");

  if (!actionButton) {
    return;
  }

  const row = actionButton.closest(".expense-row");
  const expenseId = row.dataset.expenseId;

  if (actionButton.dataset.action === "approve") {
    approveExpense(expenseId);
  } else if (actionButton.dataset.action === "reject") {
    openRejectPanel(expenseId);
  }
});

let panelListeners = null;

function openRejectPanel(expenseId) {
  panelListeners = new AbortController();

  document.addEventListener(
    "keydown",
    (event) => {
      if (event.key === "Escape") {
        closeRejectPanel();
      }
    },
    { signal: panelListeners.signal }
  );

  showPanel(expenseId);
}

function closeRejectPanel() {
  panelListeners?.abort();   // removes every listener registered with that signal
  panelListeners = null;
  hidePanel();
}

Think about it

Think about it

A colleague makes rows clickable by putting a click listener on each div.expense-row and adding a keydown handler for Enter. What is still missing, and what would you do instead?

Show solution

The div is still not focusable, so the keydown handler never fires — you cannot press Enter on something you cannot reach with Tab. It is also announced as plain text, so a screen reader user has no reason to think it is interactive, and Space does not activate it.

Fixing it properly by hand means tabindex, a role, key handling for both Enter and Space, and a visible focus style. That is four additions to re-create behaviour that already exists.

Put a button inside the row for the action, or an anchor if it navigates somewhere. If the whole row really must be the target, make the row a button element. The point from the semantic HTML lesson applies directly: the element you choose decides what you get for free.

Saved in this browser only.