Advanced DOM in JavaScript: Navigation, closest, dataset, template and Batched Updates
We navigate the DOM with parent, children, siblings and closest, use dataset, insertAdjacentHTML, DocumentFragment, template, cloneNode and getBoundingClientRect, then meet reflow and batch our updates.
Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).
Predict First
A button carries two data- attributes. When you open the file below, which two lines appear in the console?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Predict</title>
</head>
<body>
<button id="buy" data-product-id="42" data-in-stock="false">Buy</button>
<script>
const button = document.querySelector("#buy");
console.log(button.dataset.productId + 1);
console.log(button.dataset.inStock ? "in stock" : "sold out");
</script>
</body>
</html>
Make a guess before you keep reading. Both lines hide a small trap, and both rest on a rule we saw in the Basics series. We'll get to the answer after we've covered dataset.
Navigating the DOM Tree
In the Basics series we found elements with querySelector. Often, though, you already have an element in hand and want to reach another one positioned relative to it: its container, its neighbor, its contents. The DOM is like a family tree. Every element has a parent, children and siblings. Save this file, open it and look at the console:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>DOM navigation</title>
</head>
<body>
<ul id="menu">
<li>Home</li>
<li class="active">Blog</li>
<li>Contact</li>
</ul>
<script>
const active = document.querySelector(".active");
console.log(active.parentElement.id); // "menu"
console.log(active.previousElementSibling.textContent); // "Home"
console.log(active.nextElementSibling.textContent); // "Contact"
const menu = document.querySelector("#menu");
console.log(menu.firstElementChild.textContent); // "Home"
console.log(menu.lastElementChild.textContent); // "Contact"
console.log(menu.children.length); // 3
console.log(menu.childNodes.length); // 7
</script>
</body>
</html>
The last two lines look odd at first. There are three li elements in the menu, yet childNodes says seven. That's because the DOM contains not only elements but also text nodes. The line breaks and indentation you put between the li elements in your HTML each enter the tree as a text node. children counts only elements; childNodes counts everything.
The same distinction applies to siblings. nextSibling will often hand you that whitespace text, while nextElementSibling gives you the next real element. In day-to-day work you'll almost always use the versions with "Element" in their name: parentElement, children, firstElementChild, lastElementChild, previousElementSibling, nextElementSibling. When there's no element left in that direction, they all return null.
closest: Searching Upward
The handiest navigation tool is closest. It starts with the element itself and climbs up the tree until it finds the first element that matches the selector you gave it. If it finds none, it returns null. Save this file; we'll use the same page in the next section too:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>closest</title>
</head>
<body>
<article class="card" data-id="7">
<h3>Mechanical keyboard</h3>
<div class="actions">
<button class="delete">Delete</button>
</div>
</article>
<script>
const deleteButton = document.querySelector(".delete");
const card = deleteButton.closest(".card");
console.log(card.dataset.id); // "7"
console.log(deleteButton.closest("button") === deleteButton); // true
console.log(deleteButton.closest(".sidebar")); // null
</script>
</body>
</html>
Think of an elevator: you start on your own floor and go up until you see the sign you're looking for. It's ideal for jobs like finding which card to remove when its "Delete" button is clicked, or working out which menu an item belongs to. Where closest really shines is event delegation, which we'll see in the next post.
dataset: Carrying Data on the Element
Every HTML attribute whose name starts with data- is your own custom data. The browser gives these no special meaning; you read and write them from JavaScript through dataset. The naming rule is this: the data- prefix is dropped and any letter after a dash becomes uppercase. data-product-id becomes dataset.productId. Try this in the console of the page from the closest example:
const card = document.querySelector(".card");
console.log(card.dataset.id); // "7"
card.dataset.status = "sold"; // adds data-status="sold" to the HTML
card.dataset.lastViewed = "today"; // data-last-viewed="today"
delete card.dataset.status; // removes the attribute
console.log(Number(card.dataset.id) + 1); // 8
The most important part of the rule is this: every value in dataset is a string. You get a string when you read, and whatever you assign is converted to a string when you write.
Now back to the opening question. button.dataset.productId is not the number 42 but the string "42". When a string meets a number with +, you get concatenation instead of addition: the result is "421". On the second line, dataset.inStock is not false but the string "false". Since every non-empty string is truthy, the ternary prints "in stock". The answer is 421 and in stock. The right way is to use Number(...) for numbers and compare explicitly for booleans, with dataset.inStock === "true".
Another nice thing about data- attributes is that CSS can reach them. With a selector like .card[data-status="sold"] you can fade out sold cards, and when the state changes you just update dataset.status to change how it looks.
insertAdjacentHTML and Its Siblings
In the Basics series we saw how to rewrite an element's content from scratch with innerHTML. Sometimes, though, you want to add something at a specific spot without touching the existing content. insertAdjacentHTML inserts HTML at one of four positions. Put the first block in the body of an empty HTML file and run the second block in that page's console:
<!-- beforebegin -->
<ul id="list">
<!-- afterbegin -->
<li>Existing item</li>
<!-- beforeend -->
</ul>
<!-- afterend -->
const list = document.querySelector("#list");
list.insertAdjacentHTML("afterbegin", "<li>At the start</li>");
list.insertAdjacentHTML("beforeend", "<li>At the end</li>");
list.insertAdjacentHTML("beforebegin", "<h3>List heading</h3>");
list.insertAdjacentHTML("afterend", "<p>Below the list</p>");
beforebegin and afterend insert outside the element, as a sibling. afterbegin and beforeend insert inside it, as the first or last child.
Why not list.innerHTML += "<li>...</li>"? Because however innocent += looks, it turns all the existing content into a string, appends the new part and rebuilds everything from scratch. Event listeners attached to the old elements are lost and values typed into inputs are wiped. insertAdjacentHTML leaves existing nodes alone.
The security warning applies here too: never pass data from a user or a server to these functions as HTML. If you want to add text, there's insertAdjacentText; for a ready-made element there's insertAdjacentElement, or its more readable siblings before(), after(), prepend() and append().
DocumentFragment, template and cloneNode
Adding a hundred items to a list one by one means telling the browser "the page changed" a hundred times. Instead, you can line the items up on a tray first and set the tray on the table in one go. That tray is called a DocumentFragment: a lightweight container for nodes that isn't attached to the page. When you add it to the page, the fragment itself disappears and its contents take its place.
The HTML template element goes hand in hand with it. The HTML you write inside it is parsed but not displayed, its scripts don't run and its images don't load. It just waits, like a mold. Save the file below and open it:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>template and fragment</title>
</head>
<body>
<ul id="products"></ul>
<template id="product-template">
<li class="product">
<strong class="name"></strong>
<span class="price"></span>
</li>
</template>
<script>
const products = [
{ id: 1, name: "Headphones", price: 59 },
{ id: 2, name: "Keyboard", price: 110 },
{ id: 3, name: "Mouse", price: 39 },
];
const template = document.querySelector("#product-template");
const fragment = document.createDocumentFragment();
for (const product of products) {
const item = template.content.cloneNode(true);
const li = item.querySelector(".product");
li.dataset.id = product.id;
item.querySelector(".name").textContent = product.name;
item.querySelector(".price").textContent = ` $${product.price}`;
fragment.append(item);
}
document.querySelector("#products").append(fragment);
</script>
</body>
</html>
template.content is a DocumentFragment holding the template's contents. For each product we copy it with cloneNode(true), fill it safely with textContent and add it to the fragment. The page is touched only at the very end, with a single append. The HTML structure stays in HTML and the logic stays in JavaScript; much cleaner than building HTML by gluing strings together.
cloneNode has a few subtleties. With true the whole subtree is copied; leave the argument out and it defaults to false, copying only the element itself, empty inside. Attributes are copied, but listeners added with addEventListener are not. And if the element you copy has an id, you end up with two identical ids on the same page; change the copy's before adding it to the page.
Measuring an Element: getBoundingClientRect
To position a tooltip right below a button, or to tell whether an element is visible on screen, you need to know where it is and how big it is. getBoundingClientRect() tells you. Try it in the console of the page from the template example:
const box = document.querySelector("#products");
const rect = box.getBoundingClientRect();
console.log(rect.width, rect.height); // size in pixels
console.log(rect.top, rect.left); // position relative to the top-left of the visible area
const pageTop = rect.top + window.scrollY; // position relative to the top of the page
const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
console.log(isVisible);
The values are relative to the visible area, the viewport. As you scroll the page, top changes. If you want the position relative to the very top of the page, add window.scrollY. We'll see a more efficient way to keep track of visibility while scrolling with the Observer APIs in the Advanced series.
Reflow, Repaint and Batched Updates
When you change the DOM, the browser doesn't redraw the screen immediately. First it computes the styles, then it works out where every element sits and how big it is (this is called layout, or reflow), and finally it paints the pixels (repaint). Changing a text's color usually only needs a repaint. But changing a width, adding new elements or enlarging the font size can affect the position of everything around it, so it needs a reflow and costs more.
The browser is smart: it collects your changes and processes them together at the next paint. What breaks that batching is measuring right after writing. When you ask for a measurement such as offsetHeight or getBoundingClientRect(), the browser has to compute all pending changes on the spot to give you a correct answer. Do that inside a loop and you trigger a forced reflow on every iteration. Again on the page from the template example:
const items = document.querySelectorAll(".product");
// Bad: write, then read, on every iteration. Each read triggers a reflow.
for (const item of items) {
item.style.height = "40px";
console.log(item.offsetHeight);
}
// Good: read everything first, then write everything.
const heights = [...items].map((item) => item.offsetHeight);
items.forEach((item, i) => {
item.style.height = `${heights[i] + 10}px`;
});
A few practical habits help with batched updates. If you're adding lots of elements, collect them in a fragment and add them in one go. If you're refreshing a list entirely, swap the old contents in one move with replaceChildren(). If you're changing several styles on an element, define a class in CSS and add it with classList.add instead of touching style property by property. Keep reads and writes apart. We'll go deeper into this, into layout thrashing and measuring with the DevTools Performance tab, in the Advanced series.
Common Pitfalls
The first is mixing up children and childNodes, or nextElementSibling and nextSibling. The versions without "Element" in their name count text nodes too and may hand you a whitespace character.
The second is assuming dataset values are numbers or booleans. As we saw in the opening question, everything is a string; do the conversion yourself.
The third is adding content with innerHTML +=. All existing content is rebuilt from scratch, and listeners and form values are gone. To add things, use insertAdjacentHTML, append or a fragment.
The fourth is cloning the template itself. template.cloneNode(true) just gives you another invisible template; what you need to clone is template.content.
The fifth is forgetting that closest or querySelector can return null. Check the result before using it on the next line, or use the ?. operator we saw earlier.
A Small Experiment
★☆☆ In the menu from the navigation example, write code that moves the .active class to the next li. When it's on the last item, it should wrap back to the first. Hint: if nextElementSibling returns null, go to parentElement.firstElementChild.
★★☆ Predict the output, then try it:
const div = document.createElement("div");
div.dataset.count = 5;
div.dataset.count += 1;
div.dataset.userName = "Ada";
console.log(div.dataset.count);
console.log(div.outerHTML);
★★★ Change the template example so that it fetches its data, as in the previous post, from https://jsonplaceholder.typicode.com/users with fetch. Adjust the template to show each user's name and city (address.city), add the cards to the page with a single fragment, and show "Loading..." in place of the list until the data arrives.
In this post we found elements, measured them, cloned them and added them in batches. But once you've added a hundred cards to a list, attaching a separate listener to each "Delete" button is a burden of its own. In the next post we'll see how events travel through the DOM tree, bubbling and capturing, event delegation that manages hundreds of elements with a single listener, and creating our own events.