Skip to main content
Published / updated

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
  • textContent versus innerHTML
  • 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:

EventFires on
clickClick, and Enter on a focused button or link
inputEvery keystroke or change in a field
changeValue committed — on blur for text, immediately for select
submitForm submission
focus / blurDo not bubble
focusin / focusoutSame, but bubble — use these for delegation
keydown / keyupKeyboard
pointerdown / pointerupMouse, 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:

PropertyFailed rule
valueMissingrequired
typeMismatchtype="email", type="url"
patternMismatchpattern
tooShort / tooLongminlength / maxlength
rangeUnderflow / rangeOverflowmin / max
stepMismatchstep
customErrorSet by setCustomValidity
validEverything 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 seeCauseFix
Cannot read properties of null from querySelectorScript ran before the element existedPut the script at the end of <body>, or use DOMContentLoaded
Handler fires for old elements onlyBound before the list was re-renderedUse event delegation on the container
The page reloads on every button clickButton inside a form defaults to type="submit"type="button"
Handler runs several timesBound more than onceRemove before rebinding
User-entered text renders as HTMLUsed innerHTMLUse 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
  • innerHTML with user or API data
  • Setting styles from JavaScript instead of toggling classes
  • One listener per row instead of delegation
  • Using event.target where closest() was needed
  • Forgetting event.preventDefault() on submit, causing a reload
  • Delegating blur without 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 HTMLCollection while modifying it
  • No empty state on a list
  • Trusting client-side validation

Practice

The course exercises are validate inputs and render API data.

  1. Build a student list table rendered from an array with createElement and textContent.
  2. Add an empty state and confirm it appears when the array is empty.
  3. Render with innerHTML and string concatenation instead. Put <img src=x onerror="alert(1)"> in a student name and confirm it executes.
  4. Switch to textContent and confirm the same value now displays as text.
  5. Attach delete handlers with querySelectorAll().forEach. Add a new row dynamically and confirm its button does nothing.
  6. Rewrite it with delegation on the tbody and confirm new rows work.
  7. Click an icon inside a button using event.target alone. Confirm the wrong element, then fix it with closest().
  8. Append 1,000 rows one at a time, then with a DocumentFragment. Time both.
  9. Build the student form with full ValidityState validation, aria-invalid and role="alert" errors. Test with a screen reader.
  10. Submit the form without event.preventDefault() and observe the reload.
  11. Add a live search with no debounce, type quickly, and count the requests in the Network tab. Add a 300 ms debounce and compare.
  12. Set an element's colour with element.style.color and 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 textContent rather than innerHTML for 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

  1. Why is textContent safe where innerHTML is not?
  2. What are two advantages of event delegation over per-element listeners?
  3. Why does delegating blur require capture?
  4. Why toggle a class rather than set element.style?

Next: JSON, fetch and async