The DOM and Events
Before you start
You need: arrays and objects (Article 08).
Time: about 50 minutes, plus the practice.
Learning objective
Build an interactive page that updates without reloading, handles events efficiently, and validates input safely.
Topics
- Selecting elements
- Reading and changing content
textContentversusinnerHTML- Classes, attributes and data
- Creating and removing elements
- Events and the event object
- Event delegation
- Form handling and validation
- Rendering a list efficiently
Selecting elements
document.getElementById('studentTable'); // one element, or null
document.querySelector('.student-card'); // first match, or null
document.querySelectorAll('tr[data-id]'); // static NodeList
document.getElementsByClassName('card'); // live HTMLCollection
const table = document.getElementById('studentTable');
const rows = table.querySelectorAll('tbody tr'); // scoped to the table
querySelector accepts any CSS selector, which makes it the default choice. getElementById is marginally faster and reads more clearly when you have an id.
Every selector can return null. Reading a property from it throws Cannot read properties of null:
const searchBox = document.getElementById('searchBox');
if (searchBox === null) {
console.error('Search input not found in the DOM');
return;
}
Two causes dominate: a typo in the id, and the script running before the element exists. Use defer on the script tag and the second disappears.
Static versus live
const staticRows = document.querySelectorAll('tr'); // snapshot
const liveRows = document.getElementsByTagName('tr'); // updates automatically
document.body.appendChild(newRow);
staticRows.length; // unchanged
liveRows.length; // includes the new row
A live collection is a genuine trap in a loop that adds or removes elements — the collection changes underneath you. Convert to an array:
const rows = [...document.getElementsByTagName('tr')];
A NodeList supports forEach but not map or filter. Spread it when you need array methods.
Reading and changing content
element.textContent = 'Ravi Kumar'; // text only, safe
element.innerHTML = '<strong>Ravi</strong>'; // parsed as HTML
element.innerText; // rendered text, respects CSS, slower
input.value = 'NCA-2024-0012'; // form controls use .value
checkbox.checked = true;
select.value = '10th';
textContent versus innerHTML
const parentName = "<script>alert('hacked')</script>";
element.textContent = parentName; // displays the text literally — safe
element.innerHTML = parentName; // injects markup — XSS
Use textContent for anything that came from a user or an API. innerHTML parses the string as HTML, so a parent name entered as a script tag executes in every other user's browser. That is stored cross-site scripting, and it is the most common front-end vulnerability there is.
Inline scripts inserted with innerHTML do not run, but <img src=x onerror="..."> does — so the restriction is not the protection people assume.
innerHTML is legitimate for markup you generated:
// Safe: the structure is yours, the values are escaped
tbody.innerHTML = students
.map(student => `
<tr data-id="${escapeHtml(student.publicId)}">
<td>${escapeHtml(student.rollNumber)}</td>
<td>${escapeHtml(student.name)}</td>
</tr>`)
.join('');
function escapeHtml(value) {
const div = document.createElement('div');
div.textContent = String(value ?? '');
return div.innerHTML;
}
Better still, build with createElement and textContent, which cannot be got wrong.
innerText triggers a reflow to compute rendered text and is measurably slower. Use textContent unless you specifically need what is visually rendered.
Classes, attributes and data
element.classList.add('active');
element.classList.remove('hidden');
element.classList.toggle('expanded');
element.classList.toggle('expanded', isExpanded); // force a state
element.classList.contains('active');
element.classList.replace('old', 'new');
element.getAttribute('data-id');
element.setAttribute('aria-expanded', 'true');
element.removeAttribute('disabled');
element.hasAttribute('required');
element.dataset.studentId; // reads data-student-id
element.dataset.studentId = '42';
dataset converts data-student-id to studentId — kebab-case in HTML, camelCase in JavaScript.
Toggle classes; do not set styles.
// Wrong — an inline style beats every stylesheet rule and cannot be themed
element.style.color = '#ccc';
// Right
element.classList.add('status-transferred');
.status-transferred { color: #6b7280; font-weight: 500; }
A colour set from JavaScript is unfixable from CSS, breaks dark mode, and hides the contrast problem from anyone auditing the stylesheet.
Creating and removing
const row = document.createElement('tr');
row.dataset.id = student.publicId;
const nameCell = document.createElement('td');
nameCell.textContent = student.name;
row.appendChild(nameCell);
tbody.appendChild(row);
tbody.prepend(row);
tbody.insertBefore(row, existingRow);
existingRow.after(row);
existingRow.replaceWith(row);
row.remove();
Batch with a fragment
// Slow: each append can trigger layout
for (const student of students) {
tbody.appendChild(buildRow(student));
}
// Fast: one insertion
const fragment = document.createDocumentFragment();
for (const student of students) {
fragment.appendChild(buildRow(student));
}
tbody.replaceChildren(fragment);
A DocumentFragment lives outside the document, so building it costs no layout work. replaceChildren clears and inserts in one step, replacing the old innerHTML = '' followed by appends.
Reading a layout property inside a loop that also writes is the classic performance mistake:
// Layout thrashing: read forces layout, write invalidates it, every iteration
for (const el of elements) {
el.style.height = el.offsetHeight + 10 + 'px';
}
// Read all, then write all
const heights = elements.map(el => el.offsetHeight);
elements.forEach((el, i) => { el.style.height = heights[i] + 10 + 'px'; });
Events
button.addEventListener('click', handleClick);
button.removeEventListener('click', handleClick); // needs the same reference
function handleClick(event) {
event.preventDefault(); // cancel the default action
event.stopPropagation(); // stop bubbling to ancestors
console.log(event.type);
console.log(event.target); // where it originated
console.log(event.currentTarget); // where the listener is attached
}
target versus currentTarget is the distinction that matters for delegation: target is the element clicked, currentTarget is the element the handler sits on.
Common events:
| Event | Fires on |
|---|---|
click | Click, and Enter on a focused button or link |
input | Every keystroke or change in a field |
change | Value committed — on blur for text, immediately for select |
submit | Form submission |
focus / blur | Do not bubble |
focusin / focusout | Same, but bubble — use these for delegation |
keydown / keyup | Keyboard |
pointerdown / pointerup | Mouse, touch and pen together |
input fires as the user types; change fires when they finish. Use input for live search, change for a validation that should not fire mid-word.
Bubbling
An event fires on the target, then on each ancestor up to document. That is what makes delegation possible.
element.addEventListener('click', handler, { once: true }); // auto-removes
element.addEventListener('click', handler, { capture: true }); // capture phase
element.addEventListener('scroll', handler, { passive: true }); // never calls preventDefault
element.addEventListener('click', handler, { signal: controller.signal });
passive: true on scroll and touchmove lets the browser scroll without waiting to see whether you will cancel it — a real scrolling-performance improvement.
AbortController removes many listeners at once:
const controller = new AbortController();
button.addEventListener('click', handleClick, { signal: controller.signal });
input.addEventListener('input', handleInput, { signal: controller.signal });
controller.abort(); // removes both
Event delegation
// Wrong for a long list: one listener per row, and none for rows added later
document.querySelectorAll('.delete-button').forEach(button => {
button.addEventListener('click', handleDelete);
});
// Right: one listener on the container
tbody.addEventListener('click', (event) => {
const button = event.target.closest('.delete-button');
if (button === null) {
return;
}
const row = button.closest('tr');
handleDelete(row.dataset.id);
});
Three benefits, and the second is the important one:
- One listener instead of hundreds — less memory
- Rows added later work automatically, with no re-binding
- No listener leaks when rows are removed
event.target.closest(selector) walks up from the clicked element to find the match. It is essential because a click on an icon inside a button has the icon as target, not the button.
Delegation is how every UI framework handles events internally.
Forms
const form = document.getElementById('studentForm');
form.addEventListener('submit', (event) => {
event.preventDefault(); // stop the page reload
if (!validateForm()) {
return;
}
const formData = new FormData(form);
const student = Object.fromEntries(formData.entries());
saveStudent(student);
});
FormData reads every named control, so you never enumerate fields by hand. Note two behaviours: an unchecked checkbox contributes nothing, and several controls sharing a name need formData.getAll(name).
const formData = new FormData(form);
formData.get('name');
formData.getAll('subjects');
formData.has('isHosteller');
Validation
function validateField(input) {
const errorElement = document.getElementById(input.id + 'Error');
input.setCustomValidity(''); // clear any previous custom message
if (input.validity.valueMissing) {
showError(input, errorElement, 'This field is required.');
return false;
}
if (input.validity.patternMismatch) {
showError(input, errorElement, input.title || 'Please match the required format.');
return false;
}
if (input.validity.rangeOverflow) {
showError(input, errorElement, `Maximum is ${input.max}.`);
return false;
}
clearError(input, errorElement);
return true;
}
function showError(input, errorElement, message) {
input.setAttribute('aria-invalid', 'true');
errorElement.textContent = message;
errorElement.hidden = false;
}
function clearError(input, errorElement) {
input.removeAttribute('aria-invalid');
errorElement.textContent = '';
errorElement.hidden = true;
}
The ValidityState object exposes exactly which rule failed:
| Property | Failed rule |
|---|---|
valueMissing | required |
typeMismatch | type="email", type="url" |
patternMismatch | pattern |
tooShort / tooLong | minlength / maxlength |
rangeUnderflow / rangeOverflow | min / max |
stepMismatch | step |
customError | Set by setCustomValidity |
valid | Everything passed |
aria-invalid and a role="alert" error element are what make the failure perceivable non-visually. Colour alone is not enough.
form.addEventListener('blur', (event) => {
if (event.target.matches('input, select, textarea')) {
validateField(event.target);
}
}, true); // capture, because blur does not bubble
Validate on blur rather than on every keystroke — flagging an email as invalid while the user is still typing it is hostile.
None of this is a security control. Every attribute is removable in DevTools and the request can be sent without the page. The server must validate independently; this only saves a round trip.
Debouncing
function debounce(fn, delay) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delay);
};
}
const searchInput = document.getElementById('searchTerm');
searchInput.addEventListener('input', debounce((event) => {
loadStudents(event.target.value);
}, 300));
Without this, typing "Ravi Kumar" fires ten requests and the responses can arrive out of order — so the list ends up showing results for "Ravi Kuma".
Rendering a list
function renderStudents(students) {
const tbody = document.getElementById('studentRows');
const emptyMessage = document.getElementById('emptyMessage');
if (students.length === 0) {
tbody.replaceChildren();
emptyMessage.hidden = false;
return;
}
emptyMessage.hidden = true;
const fragment = document.createDocumentFragment();
for (const student of students) {
fragment.appendChild(buildStudentRow(student));
}
tbody.replaceChildren(fragment);
}
function buildStudentRow(student) {
const row = document.createElement('tr');
row.dataset.publicId = student.publicId;
row.appendChild(createCell(student.rollNumber));
row.appendChild(createCell(student.name));
row.appendChild(createCell(`${student.className} - ${student.section}`));
const actionsCell = document.createElement('td');
const editButton = document.createElement('button');
editButton.type = 'button';
editButton.className = 'edit-button';
editButton.textContent = 'Edit';
actionsCell.appendChild(editButton);
row.appendChild(actionsCell);
return row;
}
function createCell(text) {
const cell = document.createElement('td');
cell.textContent = text ?? '';
return cell;
}
textContent throughout, so no escaping is needed and XSS is impossible. One insertion via a fragment. An explicit empty state, because a table with headers and no rows reads as a broken page.
For a template-driven approach:
<template id="studentRowTemplate">
<tr>
<td class="roll"></td>
<td class="name"></td>
<td><button type="button" class="edit-button">Edit</button></td>
</tr>
</template>
const template = document.getElementById('studentRowTemplate');
function buildStudentRow(student) {
const row = template.content.cloneNode(true);
row.querySelector('.roll').textContent = student.rollNumber;
row.querySelector('.name').textContent = student.name;
row.querySelector('tr').dataset.publicId = student.publicId;
return row;
}
<template> content is inert — not rendered, images not fetched — until cloned. The markup lives in the HTML where it belongs.
Errors you will hit
| What you see | Cause | Fix |
|---|---|---|
Cannot read properties of null from querySelector | Script ran before the element existed | Put the script at the end of <body>, or use DOMContentLoaded |
| Handler fires for old elements only | Bound before the list was re-rendered | Use event delegation on the container |
| The page reloads on every button click | Button inside a form defaults to type="submit" | type="button" |
| Handler runs several times | Bound more than once | Remove before rebinding |
| User-entered text renders as HTML | Used innerHTML | Use textContent |
innerHTML with user input is an XSS hole. textContent renders the same characters safely.
Common mistakes
- Not checking a selector for
null - A script running before the DOM exists — use
defer innerHTMLwith user or API data- Setting styles from JavaScript instead of toggling classes
- One listener per row instead of delegation
- Using
event.targetwhereclosest()was needed - Forgetting
event.preventDefault()on submit, causing a reload - Delegating
blurwithout capture - Appending in a loop instead of using a fragment
- Reading and writing layout in the same loop
- No debounce on a live search
- Iterating a live
HTMLCollectionwhile modifying it - No empty state on a list
- Trusting client-side validation
Practice
The course exercises are validate inputs and render API data.
- Build a student list table rendered from an array with
createElementandtextContent. - Add an empty state and confirm it appears when the array is empty.
- Render with
innerHTMLand string concatenation instead. Put<img src=x onerror="alert(1)">in a student name and confirm it executes. - Switch to
textContentand confirm the same value now displays as text. - Attach delete handlers with
querySelectorAll().forEach. Add a new row dynamically and confirm its button does nothing. - Rewrite it with delegation on the tbody and confirm new rows work.
- Click an icon inside a button using
event.targetalone. Confirm the wrong element, then fix it withclosest(). - Append 1,000 rows one at a time, then with a
DocumentFragment. Time both. - Build the student form with full
ValidityStatevalidation,aria-invalidandrole="alert"errors. Test with a screen reader. - Submit the form without
event.preventDefault()and observe the reload. - Add a live search with no debounce, type quickly, and count the requests in the Network tab. Add a 300 ms debounce and compare.
- Set an element's colour with
element.style.colorand then try to override it from CSS. Then use a class instead.
Then run the course debugging exercise — investigate a console error. Trigger each and read the stack trace: a null from a misspelled id, a script running before the DOM, calling a method on undefined from an unchecked find, and a typo in a method name.
Exercises 3 and 4 are the security lesson. Do them.
You can now
- Select and update elements safely
- Use
textContentrather thaninnerHTMLfor user data - Handle events, including delegation for dynamic lists
- Set
type="button"on buttons that must not submit - Run code only after the DOM is ready
Review questions
- Why is
textContentsafe whereinnerHTMLis not? - What are two advantages of event delegation over per-element listeners?
- Why does delegating
blurrequire capture? - Why toggle a class rather than set
element.style?
Next: JSON, fetch and async