Events, Forms, and User Input in JavaScript
JavaScript event listeners run a function when someone clicks, types, or submits a form. Use addEventListener to register the function, FormData to read named form controls, and preventDefault when your code takes over submission.
This is part of our JavaScript and the DOM guides. Start with the JavaScript overview if functions and objects are new to you.
These examples need a browser rather than Node. Save an HTML file, load your script from it with <script defer src="app.js"></script>, and keep the Console tab open. The FormData object examples are the exception, since they also run in Node.
How Do You Add a JavaScript Event Listener?
addEventListener takes the name of an event and a function to run when it happens. The function is a callback, exactly as covered in Functions, Callbacks, and Closures:
const refreshButton = document.querySelector("#refresh");
refreshButton.addEventListener("click", () => {
console.log("the button was clicked");
}); Add <button id="refresh" type="button">Refresh</button> to the page before running this snippet. Clicking it logs the message. The explicit type="button" also prevents submission if you later put the button inside a form.
The event names you will use most are click, input, change, submit on a form, and keydown. Names are lowercase strings with no on prefix, so it is "click" rather than "onclick".
input fires when a user changes a control's value or editable content, including typing, pasting, and deleting. Assigning element.value in JavaScript does not fire it automatically. change fires when the control commits a change; the timing depends on the control.
You can attach several listeners to the same element and event, and within one phase they run in the order you added them. Registering the identical function again for the same type and capture setting is ignored, so duplicates need different functions.
That is the main practical advantage over an onclick attribute, which holds only one handler and can be blocked by a strict Content Security Policy.
What Does a JavaScript Event Object Contain?
A JavaScript event object describes what happened. Its type names the event, target identifies where it was dispatched, and currentTarget identifies the object whose listener is running.
document.querySelector("#tasks").addEventListener("click", (event) => {
console.log(event.type);
console.log(event.target.tagName);
console.log(event.currentTarget.id);
}); Click a button inside that list and event.target is the button, while event.currentTarget is the list you attached the listener to. They differ because an event does not stop where it was dispatched.
A dispatched event travels down from the top to that element first, in what is called the capture phase, fires there, then travels back up through each ancestor. That upward leg is bubbling, and it is what makes the next section possible.
Bubbling is not universal. It happens only for events that declare it, so focus and blur do not bubble and their bubbling cousins focusin and focusout are used instead. Events crossing out of a web component's shadow tree get their reported target rewritten to the host.
Other events carry their own extra properties. A keyboard event has key, holding the character or a name such as "Enter", and a mouse event has clientX and clientY. For a form control, an input event's value is read from event.target.value, which a contenteditable element does not have.
How Does preventDefault Stop a Form or Link Action?
Some elements act on their own. An anchor with an href navigates, a form runs the browser's submission algorithm, a right click opens a menu. preventDefault tells the browser to skip that built-in action and leave the outcome to you:
document.querySelector("#help-link").addEventListener("click", (event) => {
event.preventDefault();
document.querySelector("#help-panel").classList.add("is-open");
}); The panel opens and the page keeps its scroll position. Without that call the browser follows the link, and a navigation discards whatever your handler was still doing. Call it while the handler is still running, before any await, rather than after.
Not every event can be canceled. An event carries a cancelable flag, and calling preventDefault on one that is not cancelable does nothing at all. Here is that behavior probed directly, using the same event machinery the DOM is built on:
const target = new EventTarget();
target.addEventListener("submit", (event) => event.preventDefault());
const cancelable = new Event("submit", { cancelable: true });
const plain = new Event("submit");
console.log("cancelable:", target.dispatchEvent(cancelable), cancelable.defaultPrevented);
console.log("not cancelable:", target.dispatchEvent(plain), plain.defaultPrevented); // node output:
// cancelable: false true
// not cancelable: true false The dispatch call returns false once a cancelable event has been prevented, and defaultPrevented records it. Real DOM events such as submit and a link's click are cancelable, while scroll is not.
One thing preventDefault does not do is stop the event traveling upward. That is stopPropagation, a different method, and one worth using sparingly because it hides events from listeners further up that had every right to see them.
How Does JavaScript Event Delegation Work?
JavaScript event delegation places a listener on a parent and uses events from its descendants to decide what to do. A task list can handle all its Delete buttons with one click listener:
<ul id="tasks">
<li>Send the report <button data-action="delete" data-item-id="42">Delete</button></li>
<li>Book the room <button data-action="delete" data-item-id="43">Delete</button></li>
</ul> document.querySelector("#tasks").addEventListener("click", (event) => {
const button = event.target.closest("[data-action='delete']");
if (!button) return;
button.closest("li").remove();
}); Click any Delete button and its row disappears. Click anywhere else in the list and nothing happens, because the guard returned early.
That closest call matters. It walks upward from the clicked element, checking the element itself first, and returns the nearest match or null when there is none. Without it a click that landed on an icon inside the button would miss.
This pattern is called delegation, and it buys two things. Rows added later are handled with no extra code, since the listener lives on the container. And there is one function to remove when the feature goes away rather than twenty.
It works for events that reach the container, which covers click and most of what you will delegate. For focus and blur, which do not bubble, delegate focusin and focusout instead, or register with the capture option.
How Do You Read Form Values with FormData?
new FormData(form) reads the form's named, enabled controls. It skips unchecked checkboxes and other controls that do not contribute a submitted value. Use get for one value or getAll for repeated names.
The HTML guide explains the elements and attributes used here. The /api/signup URL is an example endpoint you must implement; this snippet does not create a signup service.
<form id="signup">
<label>Email <input type="email" name="email" required /></label>
<label>Topic <input type="text" name="topic" value="billing" /></label>
<button type="submit">Sign up</button>
</form> document.querySelector("#signup").addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const body = Object.fromEntries(data);
await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}); The name attribute is what makes a field appear. A field without one is left out entirely, and so is a disabled field or an unchecked checkbox, which is the usual reason a value goes missing on the server.
The object side behaves in ways worth seeing before they surprise you. This part is not DOM specific, so it also runs in Node:
const data = new FormData();
data.append("email", "sam@example.com");
data.append("topic", "billing");
data.append("topic", "shipping");
data.append("quantity", 3);
console.log("get:", data.get("topic"));
console.log("getAll:", data.getAll("topic"));
console.log("missing:", data.get("nope"));
console.log("as an object:", Object.fromEntries(data));
console.log("quantity type:", typeof data.get("quantity")); // node output:
// get: billing
// getAll: [ 'billing', 'shipping' ]
// missing: null
// as an object: { email: 'sam@example.com', topic: 'shipping' }
// quantity type: string Three findings there matter. A missing field gives null rather than an error, and Object.fromEntries keeps only the last value when a name repeats, so a checkbox group needs getAll or a loop over the entries instead.
The third is that a number you appended came back as the string "3". Every value is either a string or a file, and anything else you append is converted to a string on the way in.
You can also send the FormData object straight as a fetch body, with no Content-Type header of your own, and the browser encodes it and sets the header itself. That is the path to take when the form uploads a file.
One trap sits next to this. form.submit() does not fire the submit event and skips validation, so a script that calls it bypasses your handler entirely. Use form.requestSubmit() when you want to submit a form from code and still be treated like a real submission.
Which Form Validation Rules Does HTML Provide?
HTML provides required fields, email and number input types, length limits, numeric ranges, and pattern checks. Add those attributes before writing custom JavaScript validation.
On an ordinary submission, a failing rule stops the browser's submission algorithm before the submit event, and the browser shows its own message. So your handler normally runs only once the built-in rules have passed.
Three things opt out of that: novalidate on the form, formnovalidate on the submit button, and calling form.submit() from script, which skips validation and the event together.
Two methods let your script work with the same system. checkValidity reports whether a field or form passes without showing the browser's message, though it does fire an invalid event on each failing control, while reportValidity checks and shows the message:
const form = document.querySelector("#signup");
const email = document.querySelector("[name='email']");
console.log(email.checkValidity());
form.addEventListener("submit", (event) => {
event.preventDefault();
if (!form.reportValidity()) return;
console.log("passed validation");
}); For a rule the browser has no idea about, such as two passwords matching, use setCustomValidity. Setting a non-empty string marks the field invalid with your wording, and setting an empty string clears that custom error, though the field can still fail on required, its type, or another attribute:
confirmField.setCustomValidity(
confirmField.value === passwordField.value ? "" : "The two passwords do not match",
); Remember what this is and is not. Everything here happens on the person's own machine, where it can be edited or skipped, so it is a courtesy to honest users rather than a guarantee. The server has to check the same things again.
How Do You Prevent Repeated Form Submissions?
Disable the submit button while a form request is pending and restore it in finally. This reduces repeated clicks; the server should still prevent duplicate writes where they matter. Use this handler instead of the earlier submit example, not alongside it.
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button[type='submit']");
const original = button.textContent;
button.disabled = true;
button.textContent = "Sending...";
try {
const response = await fetch("/api/signup", { method: "POST", body: new FormData(form) });
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
form.querySelector(".status").textContent = "Thanks, you are on the list.";
} catch (error) {
form.querySelector(".status").textContent = "Something went wrong. Please try again.";
} finally {
button.disabled = false;
button.textContent = original;
}
}); The reader sees the button stop responding and read Sending, then come back with either a thank you or an apology, with the exact disabled styling coming from your CSS or the browser's default. The finally block is what guarantees the second half, since a thrown error would otherwise leave the button disabled forever.
The response.ok check is not optional, for the reasons the fetch guide sets out. A failed request that never throws would show your success message.
A disabled control is excluded from FormData, so disable the button rather than the fields before collecting values. Add a visible status element such as <p class="status" role="status"></p> inside the form so the handler has somewhere to show and announce the result.
Pitfalls and Debugging
The page navigates when the form is submitted. Likely nothing called preventDefault, or it was called after an await, or something threw before it, or the listener sits on the button rather than the form. Listen for submit on the form itself, which also covers somebody pressing Enter in a field.
The handler runs twice. Most often the listener was added more than once, because the setup code runs on every render or every time a panel opens, and the cleanup guide covers that case. It can also be one listener seeing two real events, so log the event and its target before assuming.
A field is missing on the server. Check for a name attribute, then check whether the control is disabled or an unchecked checkbox. All three are excluded from FormData by design.
Clicks stop working on new rows. Listeners were attached to elements that have since been replaced. Delegate from a container that survives the update.
A click on an icon inside a button does nothing. The event started on the icon, so a check such as event.target.matches("button") fails. Use event.target.closest("button") instead.
Nothing happens and there is no error. Log inside the listener first to see whether it runs at all. If it never runs, work through the candidates: the selector, the timing, the event name, a control that cannot be activated, a listener already removed or aborted, or something calling stopPropagation further down the path.
The Enter key submits when you did not expect it. Forms support implicit submission, and whether it happens depends on the fields present and whether the form has a default submit button. A button inside a form is type="submit" unless you say otherwise, so write type="button" on any button that is not meant to submit.
Frequently Asked Questions
Should JavaScript click handlers use onclick attributes?
Prefer addEventListener for JavaScript click handlers so behavior stays separate from the HTML and multiple listeners can be registered. An onclick attribute still works where policy permits, but it occupies a single handler slot and may be blocked by a strict Content Security Policy.
Why does an HTML form reload the page when submitted?
An HTML form normally navigates when submitted. If JavaScript should handle the submission instead, call preventDefault in the form's submit listener before any await. Check that the listener registered and did not throw before that call.
How do JavaScript event.target and event.currentTarget differ?
JavaScript event.target identifies where an event was dispatched, while event.currentTarget identifies the object whose listener is currently running. With a delegated click listener, the target may be a child button and currentTarget the containing list. Shadow DOM can retarget the reported target.
Do you need a JavaScript library for form validation?
You do not need a JavaScript library for basic form validation. HTML supports required fields, email types, ranges, length limits, and patterns. Add custom rules when needed, and validate submissions on the server as well.
Sources
-
[1]
Introduction to Events(developer.mozilla.org)
-
[2]
HTMLFormElement: submit Event(developer.mozilla.org)
-
[3]
FormData() Constructor(developer.mozilla.org)
Read Next
removeEventListener and why it so often does nothing, retiring listeners with one AbortController, script defer against DOMContentLoaded, and why these leaks survive a framework.
querySelector and querySelectorAll, textContent against innerHTML and when each is safe, adding and removing classes, attributes and data values, and building or deleting elements.
The JavaScript that changes a web page: finding and updating elements, reacting to clicks and forms, and taking your listeners back off again.