Events, Forms, and User Input in JavaScript
By the end of this page you will be able to run code when somebody clicks or types, stop a form reloading the page, serve a whole list with one listener, read what was filled in, and keep the submit button honest while a request is in flight.
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 section is the exception: that part is not DOM specific and its labelled output blocks were produced by running the code under Node 24.16.0.
Listening for Something to Happen
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");
}); Click the button and the message appears in the console. Nothing else happens here because this is a standalone <button type="button">; the same button inside a form would submit it, since submit is a button's default type.
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 whenever the value or editable content actually changes, which covers pasting, deleting, and dictation as well as typing, and not every keypress changes a value. change fires when the control considers the value settled, and exactly when that is 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 genuinely 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 the Event Object Gives You
Your callback receives one argument describing what happened. Most people name it event, and the three parts worth learning first are the type, the element it started on, and the element currently handling it:
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.
Cancelling the Default Behaviour
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 stays where it is. 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 cancelled. An event carries a cancelable flag, and calling preventDefault on one that is not cancelable does nothing at all. Here is that behaviour 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 24.16.0 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 travelling 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.
One Listener for a Whole List
A list of twenty tasks, each with a delete button, does not need twenty listeners. Attach one to the container and let bubbling bring the clicks to you:
<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 is doing real work. 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.
Reading a Form with FormData
Reading each field with its own querySelector gets long quickly. Passing the form element to FormData collects every field at once, and the browser decides which fields count:
<form id="signup">
<input type="email" name="email" required />
<input type="text" name="topic" value="billing" />
<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 ran under 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 24.16.0 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.
Validation the Browser Already Does
Before writing any validation of your own, use the attributes. A required field, a type="email" input, minlength, min and max, and pattern are checked by the browser with no script at all.
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.
Disabled and Loading States
A submit button that stays clickable during a request invites the double submission, and that is how a person ends up subscribed twice. Disable it before the request and restore it afterwards, whatever happened:
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.
Two details finish the job. A disabled control is excluded from FormData, so disable the button rather than the fields if you are still reading them. And put the status message somewhere the reader is already looking, since a message below the fold is a message nobody sees.
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 you use onclick in the HTML?
Prefer addEventListener. An onclick attribute allows only one handler, mixes behaviour into your markup, and can be blocked by a strict Content Security Policy, which many sites run. It still works otherwise, so you will meet it in older code, but there is nothing it does that addEventListener does not do better.
Why does my form reload the page?
Because a form submission runs the browser's own submission algorithm, which normally navigates, and nothing cancels it unless you do. Call preventDefault inside the submit handler, before it awaits anything. If it still navigates, check that the handler is on the form rather than the button, that it registered at all, and that nothing threw before the call.
What is the difference between target and currentTarget?
The target is where the event was dispatched, such as the button clicked, though it is reported as the host element once the event leaves a shadow tree. The currentTarget is the element whose listener is running now, which for a delegated listener is the container. Delegation works by letting the event reach that container, then inspecting the target.
Do you need a library for form validation?
Not to start. Required fields, email and number types, minimum and maximum lengths, and pattern matching are built into HTML, and reportValidity shows the browser's own messages. Libraries earn their place when you need shared rules between the browser and a server, or messages the browser cannot phrase.
Related
- JavaScript and the DOM for the full topic overview
- Selecting Elements and Updating the Page for finding and changing the elements these handlers touch
- Listener Cleanup and Page Lifecycle for taking these listeners back off again
- Fetch, JSON, and the Network for sending what the form collected
- Functions, Callbacks, and Closures for the callbacks every listener here is built from
- HTML for the form controls and attributes this page reads
- JavaScript for the language guide and the wider learning path
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.