Listen to a form's submit event rather than a button's click — it also fires when the user presses Enter. Call event.preventDefault() to stop the browser from reloading the page, then read the values with new FormData(form).
Validate on the client to give instant feedback (required fields, ranges, formats), and show clear error messages next to the inputs. HTML attributes like required, min and max help too. Always validate again on the server — client checks can be bypassed.
Event delegation: instead of attaching a listener to every row, attach one to the parent list and use event.target.closest(...) to find which item was clicked. It works for items added later and uses less memory.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Register students</title>
<style>
.error { color: #b00020; font-size: 0.9rem; min-height: 1.2em; }
</style>
<script src="app.js" defer></script>
</head>
<body>
<form id="student-form" novalidate>
<label>Name <input name="name" /></label>
<label>Score <input name="score" type="number" /></label>
<button type="submit">Add</button>
<p class="error" id="error" role="alert"></p>
</form>
<ul id="students"></ul>
</body>
</html>const form = document.querySelector("#student-form");
const errorEl = document.querySelector("#error");
const list = document.querySelector("#students");
function validate({ name, score }) {
if (name.trim().length < 2) return "Name must have at least 2 letters.";
if (score === "" || Number.isNaN(Number(score))) return "Score must be a number.";
if (Number(score) < 0 || Number(score) > 100) return "Score must be between 0 and 100.";
return null;
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(form)); // { name, score }
const error = validate(data);
errorEl.textContent = error ?? "";
if (error) return;
const li = document.createElement("li");
li.dataset.score = data.score;
li.textContent = `${data.name.trim()} - ${data.score} `;
const remove = document.createElement("button");
remove.textContent = "Remove";
remove.className = "remove";
li.append(remove);
list.append(li);
form.reset();
form.elements.name.focus();
});
// One listener for all current and future "Remove" buttons
list.addEventListener("click", (event) => {
const button = event.target.closest("button.remove");
if (button) button.closest("li").remove();
});Key points
- Handle
submiton the form and callpreventDefault(). - Show validation messages near the input; re-validate on the server.
- Delegate events to a parent and use
closest()to find the clicked item.
Exercise
Extend the form with an email field (validate it contains "@" and a dot after it) and a form selector (1–4). Show each field's error under that field instead of one shared message, and add an "Edit" button per row using delegation.
Show solution
Try the exercise yourself first — then compare your approach with this one.
Each field has its own error element, and validate returns an object of field → message, so every problem shows next to the right input. The list's single click listener handles both Remove and Edit; Edit loads the row back into the form and removes it, so saving re-adds it.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Register students</title>
<style>.error { color: #b00020; font-size: 0.85rem; min-height: 1.1em; margin: 2px 0 8px; }</style>
<script src="app.js" defer></script>
</head>
<body>
<form id="student-form" novalidate>
<label>Name <input name="name" /></label>
<p class="error" data-error-for="name"></p>
<label>Email <input name="email" /></label>
<p class="error" data-error-for="email"></p>
<label>Form
<select name="form">
<option value="">Choose...</option>
<option>1</option><option>2</option><option>3</option><option>4</option>
</select>
</label>
<p class="error" data-error-for="form"></p>
<button type="submit">Save</button>
</form>
<ul id="students"></ul>
</body>
</html>const form = document.querySelector("#student-form");
const list = document.querySelector("#students");
function validate({ name, email, form: formNumber }) {
const errors = {};
if (name.trim().length < 2) errors.name = "Name must have at least 2 letters.";
const at = email.indexOf("@");
if (at < 1 || !email.slice(at).includes(".")) errors.email = "Enter a valid email, e.g. amina@example.com.";
if (!["1", "2", "3", "4"].includes(formNumber)) errors.form = "Choose a form from 1 to 4.";
return errors;
}
function showErrors(errors) {
for (const el of form.querySelectorAll("[data-error-for]")) {
el.textContent = errors[el.dataset.errorFor] ?? "";
}
}
function button(label, className) {
const b = document.createElement("button");
b.type = "button";
b.textContent = label;
b.className = className;
return b;
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(form));
const errors = validate(data);
showErrors(errors);
if (Object.keys(errors).length > 0) return;
const li = document.createElement("li");
Object.assign(li.dataset, { name: data.name.trim(), email: data.email, form: data.form });
li.append(`${data.name.trim()} (Form ${data.form}) - ${data.email} `, button("Edit", "edit"), " ", button("Remove", "remove"));
list.append(li);
form.reset();
});
list.addEventListener("click", (event) => {
const li = event.target.closest("li");
if (!li) return;
if (event.target.matches(".remove")) li.remove();
if (event.target.matches(".edit")) {
form.elements.name.value = li.dataset.name;
form.elements.email.value = li.dataset.email;
form.elements.form.value = li.dataset.form;
li.remove();
form.elements.name.focus();
}
});