JAVASCRIPT · TUTORIAL
Replace Inline onclick with addEventListener()
Keep HTML focused on structure and attach interactive behavior from a JavaScript file.
A button with onclick="doSomething()" may work, but its behavior is scattered into the markup. When a site grows, that makes it harder to find a handler, reuse it, or remove it. addEventListener() lets a JavaScript file attach a function to an element after the element exists. It is a small change that often makes practical maintenance easier.
The examples here use a FAQ disclosure button. Clicking it shows or hides an answer. We will first build a working version with explicit markup, then look at listener options and dynamically added content. The same pattern applies to tabs, copy buttons, calculators, and many other small website features.
Start with semantic HTML
Use a real <button> for an action. It responds to keyboard activation and communicates its role to assistive technology. The answer begins hidden, and aria-expanded describes the state of the button. A unique id connects the button to the answer through aria-controls.
<button type="button" id="faq-toggle" aria-expanded="false" aria-controls="faq-answer">
How does this work?
</button>
<div id="faq-answer" hidden>Click the button to show this answer.</div>
There is no onclick attribute in the HTML. The page still has understandable content if JavaScript is unavailable, although this particular collapsed answer cannot be opened without it. If the answer is essential, consider rendering it visible by default and letting JavaScript collapse it after loading—or use the native <details> element for a simple disclosure.
Attach the click handler
Place the following code in a JavaScript file loaded with defer, or run it after the DOM is ready. The first argument names the event type, without the on prefix. The second is the function that runs when the event occurs. Note that we pass the function; we do not call it immediately with parentheses.
const button = document.querySelector("#faq-toggle");
const answer = document.querySelector("#faq-answer");
if (button && answer) {
button.addEventListener("click", () => {
const expanded = button.getAttribute("aria-expanded") === "true";
button.setAttribute("aria-expanded", String(!expanded));
answer.hidden = expanded;
});
}
The existence checks keep this shared script from failing on pages without the FAQ. On a click, the code reads the current state and sets the next one. When the answer opens, aria-expanded becomes true and hidden becomes false. Both the visible state and the accessible state move together.
Why use a named function?
An arrow function is concise when the listener will stay in place for the page’s lifetime. If you later need to remove it, keep a reference to the original function. Passing a new arrow function to removeEventListener() will not match the earlier function, even if both have the same source code.
function handleClick() {
console.log("Button clicked");
}
button.addEventListener("click", handleClick);
// Later, with the same function reference:
button.removeEventListener("click", handleClick);
Removing a listener is useful when a temporary component is destroyed or when you want to stop responding to an event. For a one-time action, you can instead pass { once: true } as the third argument. The browser removes that listener after it runs once. Neither technique changes the fact that the event must be attached to the correct element.
Handling several matching elements
If a page has multiple FAQ buttons, querySelector() finds only the first match. Use querySelectorAll() and attach a listener to each button, or attach one delegated listener to a common parent. The direct approach is easiest when the set of elements is small and present when the page loads.
document.querySelectorAll("[data-faq-toggle]").forEach((button) => {
button.addEventListener("click", () => {
const answer = document.getElementById(button.getAttribute("aria-controls"));
if (!answer) return;
const expanded = button.getAttribute("aria-expanded") === "true";
button.setAttribute("aria-expanded", String(!expanded));
answer.hidden = expanded;
});
});
Event delegation is useful if new FAQ items are added later without re-running the setup code. A click listener on the FAQ container can inspect event.target.closest("[data-faq-toggle]") to find the button that was activated. Check that the matched button is actually inside the container before acting. This works because click events normally bubble from the target to its ancestors.
Other events and options
addEventListener() works with submit, input, change, mouseover, and many other events. A form’s submit listener can validate or prepare data before sending it. Use event.preventDefault() only when you intentionally need to stop the browser’s default action. A button’s click handler does not automatically require it when the button has type="button".
The optional third argument can contain settings such as once, capture, and passive. A passive listener promises not to call preventDefault(), which can help with certain scrolling interactions. Do not add options by habit; choose them when the event and interaction require them. If removal matters, remember that the event type, original callback, and capture setting must match.
A migration checklist
- Locate each inline handler and record which element and event it uses.
- Give the element a stable selector, such as an ID or data attribute.
- Move the function to a deferred JavaScript file and attach the listener.
- Check keyboard behavior, form defaults, and accessible state.
- Remove the inline attribute and test every page that loads the shared script.
Moving behavior out of HTML does not require a framework. A few carefully named functions in a small JavaScript file can keep an old-school PHP site understandable. The goal is to make each interaction easy to locate, test, and change without weakening the underlying HTML.
Reference: MDN addEventListener() and MDN removeEventListener().