A web page is a constant stream of events. Every click, keystroke, focus change, scroll, and form submission fires an event the browser delivers to any code that asked to hear about it. This lesson covers the events you'll meet most often, how to listen for them, and the bubbling rule that explains why a click on a button can trigger a handler on the surrounding form.
What an event is
An event is the browser's way of telling JavaScript "something just happened." A user clicked a button, pressed a key, submitted a form — each one fires an event. Code that wants to react to events listens for them.
The most common ones in test code:
click— a mouse click (or keyboard activation) on any elementinput— fired every time the value of an input changes (each keystroke)change— fired when an input loses focus and its value differed from beforesubmit— fired when a form is submittedkeydown/keyup— keyboard events (each key press / release)focus/blur— element gains / loses focusload— fired when the page (or an image, or an iframe) finishes loading
Listening with addEventListener
The standard way to react to an event is addEventListener. It takes the event name and a callback (chapter 5 syntax — same idea, different context):
const button = document.querySelector('[data-testid="submit-login"]');
button.addEventListener("click", () => {
console.log("clicked!");
});Now every time that button is clicked — by a user, by a test, by JavaScript code calling .click() directly — the callback runs.
You can attach multiple listeners to the same event on the same element. Each one runs when the event fires, in the order they were attached. There's no limit, and they don't replace each other.
The event object
Every listener receives an event object with details about what happened.
button.addEventListener("click", (event) => {
console.log("Type:", event.type); // "click"
console.log("Target:", event.target); // the actual <button> clicked
console.log("Time:", event.timeStamp); // ms since page load
});A few of the most-used fields:
event.target— the element the event happened on. Always meaningful.event.currentTarget— the element the listener is attached to (matters for bubbling, below).event.type— the event name as a string.event.key(keyboard events) — the actual key pressed:"Enter","Escape","a","ArrowDown".event.preventDefault()— a method, not a property. Stops the browser's default behaviour.
preventDefault — stopping the browser
Some events have a default behaviour. Submitting a form reloads the page. Clicking a link navigates. Pressing Enter in a single-line input submits the surrounding form. Calling event.preventDefault() tells the browser to skip that default.
const form = document.querySelector('[data-testid="login-form"]');
form.addEventListener("submit", (event) => {
event.preventDefault(); // don't actually submit / reload
console.log("Validating instead of submitting");
// run client-side validation here
});This is how single-page apps work — they intercept form submits, do client-side validation or AJAX, and update the DOM without a full page reload. Tests need to know this: the network request you expect to see only fires if the validation passes.
A real form-validation listener
A form that validates client-side and shows errors when the inputs are wrong:
const form = document.querySelector('[data-testid="login-form"]');
const email = form.querySelector('[data-testid="email-input"]');
const error = form.querySelector('[data-testid="error-message"]');
form.addEventListener("submit", (event) => {
if (!email.value.includes("@")) {
event.preventDefault();
error.hidden = false;
error.textContent = "Email looks invalid";
}
});When the user clicks Submit and the email is invalid, the form doesn't submit. The error message text changes. A test watching this form would see the error appear in the DOM — and not see the network request to /api/login.
Event bubbling
Click a button inside a div inside a form. Three elements logically saw the click — the button, the div, the form. Which one runs the handler?
The answer is all of them. After the click happens on the deepest element, the event bubbles up through every ancestor, firing handlers along the way. This is event bubbling.
const form = document.querySelector("form");
const button = form.querySelector("button");
form.addEventListener("click", () => console.log("form heard the click"));
button.addEventListener("click", () => console.log("button heard the click"));When the button is clicked:
button heard the click
form heard the click
The button's handler runs first (deepest), then the form's. Bubbling lets you attach a single listener high in the tree to handle events from many descendants — a useful pattern in real apps, and a frequent surprise when a test fires an event and unexpectedly triggers a parent's handler.
Stopping bubbling
event.stopPropagation() halts the bubble at that level. Handlers further up the tree never see the event:
button.addEventListener("click", (event) => {
event.stopPropagation();
console.log("only button hears this");
});Use sparingly. Stopping propagation can interfere with other listeners (analytics, modals that close on outside-click, the framework's own internals). Add it only when you have a specific reason.
Why this matters for testing
Every automation framework dispatches the same events the browser would dispatch:
cy.get('[data-testid="email"]').type('alice@x.com')fires a sequence ofkeydown,keypress,input, andkeyupevents — exactly what a real user would generate.page.click(...)firesmousedown,mouseup, andclick.page.fill(input, value)firesinputandchange.
When a test clicks a button and the click handler doesn't run, the mismatch is usually about events the framework didn't emit (or the app expected). Understanding the event flow lets you reason about why.
How an event actually flows
Step 1 of 6
User action
User clicks a button inside a form. The browser detects the click on the deepest element under the cursor.
You'll mostly only think about the target and bubble phases. The capture phase is an opt-in advanced feature most code ignores.
⚠️ Common mistakes
- Calling
addEventListener("click", handler()). That immediately invokeshandlerand registers its return value as the listener — usuallyundefined. Pass the function reference:addEventListener("click", handler)(no parentheses). - Forgetting
preventDefaulton form submits. A handler that does AJAX validation but forgetsevent.preventDefault()will run the validation and let the browser reload the page. The result is a flicker that a test almost can't catch. - Stopping propagation when you didn't mean to. A click handler that calls
stopPropagationcan break unrelated features — analytics tracking, "click outside to close" menus, framework internals. Only add it when you have a specific bubbling problem.
🎯 Practice task
Wire up a real listener. 15-20 minutes.
-
Create a folder
dom-demoand inside it a fileindex.html:<!doctype html> <html> <body> <form data-testid="login-form"> <input data-testid="email-input" type="email" placeholder="email" /> <button data-testid="submit-login" type="submit">Submit</button> <p data-testid="error-message" hidden></p> </form> <script src="script.js"></script> </body> </html> -
Create
script.jsnext to it:const form = document.querySelector('[data-testid="login-form"]'); const email = form.querySelector('[data-testid="email-input"]'); const error = form.querySelector('[data-testid="error-message"]'); form.addEventListener("submit", (event) => { if (!email.value.includes("@")) { event.preventDefault(); error.hidden = false; error.textContent = "Email looks invalid"; } }); -
Open
index.htmlin your browser. Click Submit with an empty input — the error appears, the page does not reload. Type a valid email and click Submit — the page does reload (because validation passed and you didn't prevent the real submit). -
Open DevTools and type
document.querySelector('[data-testid="submit-login"]').click()in the console. The handler runs as if a real user clicked. -
Stretch: add a second listener to the form for the
inputevent that hides the error as soon as the user starts editing. Verify both listeners coexist on the form.
The next (and final) lesson of this chapter ties everything together: how Cypress and Playwright are built on top of these exact DOM and event APIs, with auto-waiting and retries layered on top.