JavaScript Zero to Advanced #7: DOM Manipulation & Events
Up to now we have mostly printed things to the console. But JavaScript was born to make web pages interactive. In this part we will use the DOM to read and change a page, and events to react to what the user does. By the end you will have built a small shopping list app.
What is the DOM?
When the browser loads an HTML file, it turns it into a tree of objects called the Document Object Model. Every tag becomes an element node you can read and modify from JavaScript through the global document object. Change the DOM and the page updates immediately.
We will use this page for every example. Save it as index.html next to an app.js file:
<!DOCTYPE html>
<html>
<body>
<h1 id="title">Shopping List</h1>
<ul id="list">
<li class="item">Rice</li>
<li class="item">Eggs</li>
</ul>
<input id="new-item" type="text" placeholder="Add an item">
<button id="add-btn">Add</button>
<p id="counter">2 items</p>
<script src="app.js" defer></script>
</body>
</html>
The defer attribute tells the browser to run the script after the HTML has been parsed, so all elements exist when our code runs. The DOM only exists in the browser, so open the page and watch the DevTools console for the outputs below.
Selecting elements
Before you can change an element, you need a reference to it. The two methods you will use most are querySelector (first match) and querySelectorAll (all matches). Both accept any CSS selector.
const title = document.getElementById("title")
const items = document.querySelectorAll(".item")
const firstItem = document.querySelector("#list li")
console.log(title.textContent)
console.log(items.length)
console.log(firstItem.textContent)
console.log(document.querySelector(".missing"))
Output:
Shopping List
2
Rice
null
If nothing matches, querySelector returns null. Calling a method on null is one of the most common DOM errors, so check your selectors carefully.
Changing content, classes and attributes
title.textContent = "My Shopping List"
title.classList.add("highlight")
title.classList.toggle("large")
console.log(title.textContent)
console.log(title.className)
console.log(title.classList.contains("large"))
firstItem.setAttribute("data-id", "101")
console.log(firstItem.dataset.id)
console.log(firstItem.getAttribute("class"))
Output:
My Shopping List
highlight large
true
101
item
Prefer toggling CSS classes over setting element.style directly: your styles stay in CSS, and JavaScript only switches states. data-* attributes are a handy way to attach data (like an id) to an element, and you read them through dataset.
textContent vs innerHTML
textContent treats the value as plain text. innerHTML parses it as HTML. Never put user input into innerHTML: an attacker could inject markup that runs scripts (an XSS attack). Look at how textContent keeps it safe:
const note = document.createElement("p")
note.textContent = "<b>Hello</b>"
console.log(note.innerHTML)
console.log(note.children.length)
Output:
<b>Hello</b>
0
The tags were escaped and displayed as text, not turned into a bold element.
Creating and removing elements
const list = document.getElementById("list")
const li = document.createElement("li")
li.textContent = "Milk"
li.classList.add("item")
list.append(li)
console.log(list.children.length)
console.log([...list.children].map((el) => el.textContent).join(", "))
list.firstElementChild.remove()
console.log(list.children.length)
console.log(list.firstElementChild.textContent)
Output:
3
Rice, Eggs, Milk
2
Eggs
append adds to the end and prepend to the start. children is an HTML collection, not an array, so we spread it into an array to use map.
Responding to events
An event is something that happens on the page: a click, a key press, a form submission, the mouse moving. You react with addEventListener(type, callback). The callback receives an event object with details about what happened.
const input = document.getElementById("new-item")
const button = document.getElementById("add-btn")
const counter = document.getElementById("counter")
function updateCounter() {
counter.textContent = `${list.children.length} items`
}
function addItem() {
const text = input.value.trim()
if (text === "") return
const li = document.createElement("li")
li.className = "item"
li.textContent = text
list.append(li)
input.value = ""
input.focus()
updateCounter()
console.log(`Added: ${text} (${counter.textContent})`)
}
button.addEventListener("click", addItem)
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") addItem()
})
Now type Coffee and click Add, then type Sugar and press Enter:
Output:
Added: Coffee (3 items)
Added: Sugar (4 items)
Notice that we pass addItem without parentheses. We hand the function to the browser, which calls it later when the event happens.
Event delegation
Items added later do not have listeners attached. Instead of adding a listener to every li, add one listener to the parent. Events bubble up from the clicked element to its ancestors, so the parent hears every click, and event.target tells you which element was actually clicked:
list.addEventListener("click", (event) => {
const item = event.target.closest("li")
if (!item) return
item.classList.toggle("done")
console.log(`${item.textContent} done: ${item.classList.contains("done")}`)
})
Click Eggs, then Coffee, then Eggs again:
Output:
Eggs done: true
Coffee done: true
Eggs done: false
Delegation works for elements that exist now and those added later, and it uses less memory. closest finds the nearest matching ancestor, which is useful when the li contains other elements such as icons.
Forms and preventDefault
By default, submitting a form reloads the page. Call event.preventDefault() to handle it with JavaScript instead:
<form id="signup">
<input name="email" type="email" required>
<button type="submit">Sign up</button>
</form>
const form = document.getElementById("signup")
form.addEventListener("submit", (event) => {
event.preventDefault()
const data = new FormData(form)
console.log("Email:", data.get("email"))
form.reset()
})
Type siti@example.com and click Sign up:
Output:
Email: siti@example.com
Common mistakes
- Running the script before the HTML exists. A script in
headwithoutdeferruns before the body is parsed, soquerySelectorreturnsnull. - Calling the handler instead of passing it.
button.addEventListener("click", addItem())runs immediately and registersundefined. - Using
innerHTMLwith user input. UsetextContentor create elements instead. - Treating a NodeList like an array.
querySelectorAllsupportsforEachbut notmap/filter; spread it first:[...nodes]. - Forgetting that input values are strings.
input.valueis always a string, even fortype="number".
Exercise
Extend the shopping list: (1) add a small "x" button inside every new li that removes the item, handled with event delegation; (2) keep the counter in sync when items are removed; (3) show a message "Your list is empty" when there are no items left; (4) prevent adding duplicates (case-insensitive). Bonus: save the list to localStorage as JSON and load it back when the page opens.
Summary
- The DOM is a tree of objects representing the page;
documentis the entry point. - Select elements with
querySelector/querySelectorAll, then changetextContent,classListand attributes. - Create elements with
createElementandappend; remove them withremove(). - React to user actions with
addEventListener; useevent.target,preventDefaultand delegation. - Never inject user input with
innerHTML.
In Part 8 we tackle asynchronous JavaScript: callbacks, Promises, async/await and fetching data from an API.
Related articles
PostgreSQL Zero to Advanced #5: Aggregation, GROUP BY & HAVING
Turn raw orders into business numbers: total revenue, average order value, sales per month and top customers using COUNT, SUM, AVG, GROUP BY, HAVING and FILTER.
PHP Zero to Advanced #5: Arrays & Array Functions
Master PHP arrays: indexed and associative arrays, nested data, destructuring, and the essential functions array_map, array_filter, array_reduce, usort, and friends.
Ruby on Rails Zero to Advanced #5: Routing, Controllers & Views
Follow a request through Rails: define routes, use resources, read params in controllers, and render dynamic HTML with ERB views and partials.