JavaScript and TypeScript course ยท Module 8: Events and the DOM
Advanced DOM APIs - Observers and Modern Interfaces
In this lesson7
Monitoring systems in Jurassic Park must react to changes in real time - a dinosaur appearing in a zone, a change in the viewing area size, mutations in data structures. JavaScript offers advanced APIs for observing these changes without the need for constant polling.
Polling is a guard who looks into every enclosure every minute and asks whether anything has changed. He gets tired, the processor gets tired, and he's still sometimes late. Observers reverse this setup: it's the browser that lets you know when something happens.
IntersectionObserver - Observing Visibility
IntersectionObserver informs you when an element enters or leaves the visible area of the page. Ideal for lazy loading, infinite scroll, and scroll-triggered animations. The constructor takes a callback and an options object: root points to the reference area (null by default, meaning the viewport), threshold sets the visibility threshold, and rootMargin a margin around that area.
// Basic usage
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log(entry.target.id + ' is visible');
entry.target.classList.add('visible');
} else {
entry.target.classList.remove('visible');
}
});
}, {
threshold: 0.5, // Requires 50% visibility
rootMargin: '100px' // Additional margin "before" the element
});
// Observe elements
document.querySelectorAll('.zone-card').forEach(card => {
observer.observe(card);
});The callback fires when the visible part of a card crosses the halfway mark, in either direction. The isIntersecting property tells you whether the element is in the visible area, and entry.target points to the element the entry is about. The exact visible fraction is given by intersectionRatio.
Lazy loading images takes four steps: images get their address in data-src instead of src, we create an observer with a callback, when an image comes into view we copy data-src to src, and finally we call unobserve().
// Lazy loading images
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src; // Load image
imageObserver.unobserve(img); // Stop observing
}
});
});
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});After unobserve(), a loaded image leaves the list of observed elements, so it takes no more attention. My advice: for plain images, first check the HTML attribute loading="lazy", which does this without JavaScript. Use the observer for everything else: animations, counters, and loading more list items.
MutationObserver - Observing DOM Changes
MutationObserver reacts to changes in the DOM tree - adding/removing elements, attribute changes, text modifications.
// Observe changes in a container
const mutObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1) { // Element
console.log('Element added:', node.tagName);
}
});
mutation.removedNodes.forEach(node => {
if (node.nodeType === 1) {
console.log('Element removed:', node.tagName);
}
});
}
if (mutation.type === 'attributes') {
console.log('Attribute changed:', mutation.attributeName);
}
});
});The callback receives an array of change records. A record of type childList carries the addedNodes and removedNodes lists, and the nodeType === 1 condition (the Node.ELEMENT_NODE constant) skips text nodes. A record of type attributes gives the name of the changed attribute. The browser delivers records in a batch, asynchronously after the changes are made, not one at a time. Time to connect it.
const container = document.getElementById('dino-list');
mutObserver.observe(container, {
childList: true, // Observe adding/removing children
attributes: true, // Observe attribute changes
subtree: true // Also observe descendants
});
// Stop observing
// mutObserver.disconnect();The options decide what we listen to, and subtree extends listening to all descendants. At least one of the childList, attributes, or characterData options must be enabled, otherwise observe() throws a TypeError. The disconnect() method ends the observation.
ResizeObserver - Observing Size Changes
ResizeObserver reacts to element size changes - not just the window, but any DOM element.
const resizeObserver = new ResizeObserver((entries) => {
entries.forEach(entry => {
const { width, height } = entry.contentRect;
console.log(entry.target.id + ':', width + 'x' + height);
// Adjust layout based on size
if (width < 400) {
entry.target.classList.add('compact');
} else {
entry.target.classList.remove('compact');
}
});
});
resizeObserver.observe(document.getElementById('dashboard'));A dashboard narrower than 400 pixels gets the compact class, whether the window got narrower or the panel next to it did. The callback also fires once right after observation starts, as long as the element is rendered, so the layout is correct from the start. If you only change styles, consider CSS container queries (@container), which do this without JavaScript.
Web Storage API
localStorage and sessionStorage allow you to store data in the browser. Both store text only.
// localStorage - data persists after closing the browser
localStorage.setItem('parkSettings', JSON.stringify({
theme: 'dark',
alertLevel: 3,
lastVisit: Date.now()
}));
const settings = JSON.parse(localStorage.getItem('parkSettings'));
console.log(settings.theme); // 'dark'
// sessionStorage - data disappears after closing the tab
sessionStorage.setItem('currentZone', 'A');That's why saving always takes two steps: JSON.stringify(), then localStorage.setItem(). Reading works the other way round: localStorage.getItem(), then JSON.parse(). For a key that doesn't exist, getItem() returns null. Data in sessionStorage survives a page refresh but not closing the tab.
// Listening for changes (between tabs!)
window.addEventListener('storage', (event) => {
console.log('Changed:', event.key);
console.log('Old value:', event.oldValue);
console.log('New value:', event.newValue);
});
// Removing
localStorage.removeItem('parkSettings');
localStorage.clear(); // Remove everythingThe storage event reaches other tabs of the same origin (protocol, domain, and port), not the tab that made the change, so this is how you synchronize settings between tabs. Don't store passwords or tokens here, because any script on the page can read them.
Clipboard API
The clipboard is handled by the navigator.clipboard object. Its methods return promises, which is why we work with async and await.
// Copying text
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
console.log('Copied:', text);
} catch (err) {
console.error('Copy error:', err);
}
}
// Pasting text
async function pasteFromClipboard() {
try {
const text = await navigator.clipboard.readText();
return text;
} catch (err) {
console.error('No clipboard access');
}
}The Clipboard API works only in a secure context, meaning over HTTPS or on localhost, and the browser may block reading the clipboard or make it depend on the user's permission. Hence the try...catch in both functions. Now the copy button.
// Copy button with feedback
const copyBtn = document.getElementById('copy-btn');
copyBtn.addEventListener('click', async () => {
const data = document.getElementById('sensor-data').textContent;
await copyToClipboard(data);
copyBtn.textContent = 'Copied!';
setTimeout(() => copyBtn.textContent = 'Copy', 2000);
});Thanks to await, the label changes only after the sensor data has been copied, and after two seconds it goes back to "Copy".
History API and Navigation
A single-page application can change the address in the browser bar without reloading the page.
// Adding a history entry (without page reload)
history.pushState({ zone: 'A' }, '', '/park/zone-a');
history.pushState({ zone: 'B' }, '', '/park/zone-b');
// Responding to the "Back" button
window.addEventListener('popstate', (event) => {
if (event.state) {
console.log('Returning to zone:', event.state.zone);
loadZone(event.state.zone);
}
});
// Replacing the current entry (without adding a new one)
history.replaceState({ zone: 'A', view: 'map' }, '', '/park/zone-a?view=map');The pushState() method takes a state, a title that is no longer used, and an address, and then adds a new history entry. The replaceState() method replaces the current entry. pushState() by itself doesn't trigger popstate. That event arrives only after clicking "Back" or "Forward". The if (event.state) condition protects against the starting entry, which has no state.
DocumentFragment - DOM Optimization
Adding many elements at once is expensive. DocumentFragment allows you to prepare elements "outside the DOM" and add them in a single operation:
// WITHOUT fragment - each appendChild changes the live DOM separately
const list = document.getElementById('dino-list');
const dinosaurs = ['Rex', 'Blue', 'Brachio', 'Stego', 'Trike'];
// BAD - 5 separate DOM operations
dinosaurs.forEach(name => {
const li = document.createElement('li');
li.textContent = name;
list.appendChild(li); // 5 separate changes to the live DOM
});Here each of the five iterations changes the page's live tree separately. Modern browsers group such changes and usually recalculate the layout once per frame, but when the code reads dimensions between insertions, for example offsetHeight, each read forces a separate reflow.
// GOOD - DocumentFragment
const fragment = document.createDocumentFragment();
dinosaurs.forEach(name => {
const li = document.createElement('li');
li.textContent = name;
fragment.appendChild(li); // Operation "in memory"
});
list.appendChild(fragment); // 1 change to the live DOMThe fragment lives only in memory. Inserting it moves all its children into the list in a single operation, while the fragment itself ends up empty and doesn't appear in the DOM. This keeps reflow to a minimum, and MutationObserver gets one record instead of five.
"Advanced DOM APIs are the radar and early warning systems of the Park" - Ray Arnold summarizes. "IntersectionObserver is a thermal camera, MutationObserver is an alarm system, and ResizeObserver is an adaptive control screen. Use them wisely!"
In the sandbox below you'll try out observers, DocumentFragment, and Web Storage in an interactive panel. All these tools will come in handy in the final project, an interactive park control panel.
Remember: a good park system doesn't poll the enclosures every minute, it waits for the observer to raise the alarm itself.
Code for this lesson: index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Advanced DOM API</title>
<style>
body { font-family: monospace; background: #1a1a2e; color: #e0e0e0; padding: 16px; }
h2 { color: #4ecdc4; }
.zone-card { padding: 12px; margin: 8px 0; border: 1px solid #3d3d5c; border-radius: 8px; transition: opacity 0.3s; opacity: 0.5; }
.zone-card.visible { opacity: 1; background: #16213e; }
#log { background: #0f3460; padding: 12px; border-radius: 8px; max-height: 200px; overflow-y: auto; font-size: 13px; }
.log-entry { padding: 2px 0; border-bottom: 1px solid #1a1a4e; }
button { padding: 8px 16px; margin: 4px; background: #4ecdc4; color: #1a1a2e; border: none; border-radius: 4px; cursor: pointer; }
#dino-list { list-style: none; padding: 0; }
#dino-list li { padding: 6px 10px; margin: 3px 0; background: #16213e; border-radius: 4px; }
</style>
</head>
<body>
<h2>Advanced DOM API</h2>
<h3>1. IntersectionObserver</h3>
<div id="zone-container" style="height: 120px; overflow-y: auto;">
<div class="zone-card" id="z1">Zone A - T-Rex</div>
<div class="zone-card" id="z2">Zone B - Raptors</div>
<div class="zone-card" id="z3">Zone C - Herbivores</div>
<div class="zone-card" id="z4">Zone D - Aviary</div>
<div class="zone-card" id="z5">Zone E - Aquatic</div>
</div>
<h3>2. MutationObserver + DocumentFragment</h3>
<button onclick="addDinos()">Add dinosaurs</button>
<button onclick="clearList()">Clear</button>
<ul id="dino-list"></ul>
<h3>3. Web Storage</h3>
<button onclick="saveVisit()">Save visit</button>
<button onclick="loadVisit()">Load</button>
<p id="storage-info">-</p>
<h3>Log</h3>
<div id="log"></div>
<script>
function log(msg) {
const el = document.getElementById('log');
const entry = document.createElement('div');
entry.className = 'log-entry';
entry.textContent = new Date().toLocaleTimeString() + ' ' + msg;
el.prepend(entry);
}
// IntersectionObserver
const io = new IntersectionObserver((entries) => {
entries.forEach(e => {
e.target.classList.toggle('visible', e.isIntersecting);
if (e.isIntersecting) log('Visible: ' + e.target.textContent);
});
}, { root: document.getElementById('zone-container'), threshold: 0.5 });
document.querySelectorAll('.zone-card').forEach(c => io.observe(c));
// MutationObserver
const mo = new MutationObserver((muts) => {
muts.forEach(m => {
m.addedNodes.forEach(n => {
if (n.nodeType === 1) log('Added: ' + n.textContent);
});
});
});
mo.observe(document.getElementById('dino-list'), { childList: true });
// DocumentFragment
function addDinos() {
const names = ['Rex', 'Blue', 'Brachio', 'Stego', 'Trike'];
const frag = document.createDocumentFragment();
names.forEach(n => {
const li = document.createElement('li');
li.textContent = n;
frag.appendChild(li);
});
document.getElementById('dino-list').appendChild(frag);
log('Added 5 dinosaurs (DocumentFragment)');
}
function clearList() {
document.getElementById('dino-list').innerHTML = '';
log('List cleared');
}
// Web Storage
function saveVisit() {
const data = { zone: 'A', time: new Date().toISOString(), count: Math.floor(Math.random()*10) };
localStorage.setItem('lastVisit', JSON.stringify(data));
log('Saved visit in localStorage');
}
function loadVisit() {
const raw = localStorage.getItem('lastVisit');
if (raw) {
const data = JSON.parse(raw);
document.getElementById('storage-info').textContent = 'Zone: ' + data.zone + ', Time: ' + data.time;
log('Loaded from localStorage');
} else {
document.getElementById('storage-info').textContent = 'No saved data';
}
}
log('Panel ready');
</script>
</body>
</html>Docs entries used in this lesson
Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What is IntersectionObserver used for in JavaScript?
2. What changes does MutationObserver react to?
These are 2 of 4 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
In the index.html file the setupObservers() function creates two observers: an IntersectionObserver tracks whether the #radar section is at least half visible (threshold 0.5), and a MutationObserver recounts the alerts in the #alerts list. Fill in the blanks: ___BLANK1___ is the entry property that tells whether the element is visible now, and ___BLANK2___ is the observe() option that makes the MutationObserver react when list children are added or removed. The radar status should change together with its visibility, and the #alert-count counter should show the number of alerts after every button click.
- Vertical ordering
Arrange the steps for implementing lazy loading of images with IntersectionObserver:
- Click in order
Arrange the steps for saving and reading data from localStorage:
- Code editor
The index.html file has three functions: renderDinosaurs(names) adds dinosaurs to the #dino-list list, saveConfig(config) saves the settings in localStorage under the parkConfig key, and loadConfig() reads them (without a saved value it returns { theme: "jungle" }). Fill in the blanks: ___BLANK1___ is the document method that creates a DocumentFragment, ___BLANK2___ is the expression that turns the config object into JSON text, and ___BLANK3___ is the expression that turns the raw text back into an object. The list should be filled with one appendChild call with the fragment, and a saved object should come back from loadConfig() unchanged.
- Click in order
Arrange the elements for dispatching a custom event:
- Vertical ordering
Arrange the elements of a debounce implementation on the input event:
- Vertical ordering
Arrange the elements for creating and dispatching a custom event:
- Vertical ordering
Arrange the steps for creating and dispatching a Custom Event: