Chapter 18: Working in the Browser
JavaScript’s most familiar environment is the web browser. Within it, scripts can interact directly with the page, respond to user input, communicate with servers, and store data locally. This chapter explores the essential browser APIs that make this possible, covering everything from the Document Object Model (DOM) to persistent storage mechanisms.
The DOM gives JavaScript access to the live structure of an HTML document, allowing scripts to read and modify elements dynamically. Event handling enables interactivity, letting your code respond to user actions such as clicks, keypresses, and form submissions. You will also see how to perform asynchronous network operations with the fetch API, and how to save data on the client using localStorage and sessionStorage.
Each section of this chapter focuses on one major part of the browser interface. By the end, you will have the foundation needed to build interactive pages, handle user input, and manage client-side data in a clean, standards-based way.
DOM access and querying
The Document Object Model (DOM) represents a web page as a structured hierarchy of nodes. Each HTML element, attribute, and piece of text corresponds to a node that JavaScript can access and modify at runtime. Through this model, your scripts can change page content, style, and structure dynamically.
// Accessing elements
let heading = document.querySelector('h1');
let items = document.querySelectorAll('ul li');
heading.textContent = 'Updated title';
items[0].classList.add('highlight');
Two key methods dominate modern DOM querying: querySelector(), which returns the first element matching a CSS selector, and querySelectorAll(), which returns a static collection of all matches. These replace older methods such as getElementById() and getElementsByTagName() for most modern code.
querySelector() uses standard CSS selectors, you can target elements by class, attribute, hierarchy, or any other CSS-compatible pattern.
Once an element is selected, its properties and methods allow complete control over its content and attributes. For example, you can modify text, inject HTML, or alter style declarations directly.
let para = document.querySelector('p');
para.textContent = 'This paragraph has been changed.';
para.style.color = 'steelblue';
DOM collections like those from querySelectorAll() can be iterated with for...of or converted to arrays for use with array methods such as map() and forEach(). This makes it easy to apply the same change to multiple elements at once.
document.querySelectorAll('.item').forEach(el => {
el.classList.toggle('active');
});
Shadow DOM overview
The Shadow DOM is a more advanced feature that provides encapsulation for parts of a document. It allows components to have their own isolated DOM subtree that is separate from the main page structure. Styles and scripts in a shadow tree do not leak out to the main document, and external styles do not affect its internal content.
// Creating a shadow root
let host = document.querySelector('#widget');
let shadow = host.attachShadow({ mode: 'open' });
// Add content inside the shadow tree
shadow.innerHTML = '<p>This is inside the shadow DOM</p>';
Shadow DOM is primarily used by Web Components and custom elements, but understanding its principles helps explain how modern UI libraries achieve encapsulation and predictable styling.
querySelector(). You must access them through the shadow root itself.
Together, the regular DOM and the Shadow DOM form the core of the browser’s document model, offering both flexibility and structure for building interactive, maintainable user interfaces.
Events and propagation
Events are central to how JavaScript interacts with the browser. They notify your code when something happens (such as a click, a keypress, a page load, or a network response) allowing you to react dynamically. Each event originates at a target element and then travels through the DOM in a process known as propagation.
// Basic event listener
let button = document.querySelector('button');
button.addEventListener('click', () => {
console.log('Button clicked');
});
The addEventListener() method attaches a handler function to a specific event type. Most modern code uses this approach instead of older inline attributes like onclick. You can attach multiple listeners to the same element and remove them later with removeEventListener().
'click', 'keydown', and 'input'.
When an event occurs, it follows a three-phase journey through the DOM:
- Capturing phase – the event descends from the window down to the target element.
- Target phase – the event is handled at the element where it occurred.
- Bubbling phase – the event ascends back up the DOM tree, allowing parent elements to respond.
document.body.addEventListener('click', () => {
console.log('Body handler (bubbling)');
});
document.body.addEventListener('click', () => {
console.log('Body handler (capturing)');
}, true); // true = capture phase
By default, listeners run during the bubbling phase. Setting the third argument of addEventListener() to true moves the listener to the capturing phase instead.
focus and blur do not. Their bubbling equivalents are focusin and focusout.
You can stop an event’s propagation using event.stopPropagation(), or prevent its default action (such as following a link or submitting a form) with event.preventDefault().
document.querySelector('a').addEventListener('click', event => {
event.preventDefault();
console.log('Default navigation prevented');
});
Event delegation is a powerful pattern that takes advantage of bubbling. Instead of attaching a separate handler to every child element, you attach one listener to a common ancestor and detect which child triggered it using event.target.
document.querySelector('ul').addEventListener('click', event => {
if (event.target.tagName === 'LI') {
console.log('Clicked item:', event.target.textContent);
}
});
Understanding event propagation is key to building efficient, responsive interfaces. This model forms the foundation for user interaction throughout the browser.
Forms and validation
Forms are the main way users input data into web pages. JavaScript provides full access to form elements, allowing scripts to inspect, validate, and respond to user input dynamically before it is submitted. This improves both user experience and data quality by catching errors early.
// Access form elements
let form = document.querySelector('form');
let input = document.querySelector('input[name="email"]');
// Handle form submission
form.addEventListener('submit', event => {
event.preventDefault(); // Stop automatic submission
console.log('Form submitted with:', input.value);
});
Each input, select, and textarea element exposes its current value through the value property. You can read or change it directly, and the browser will reflect that change in the interface. Events like input and change trigger whenever the user edits a field, enabling real-time feedback and validation.
input.addEventListener('input', () => {
if (input.value.includes('@')) {
input.style.borderColor = 'green';
} else {
input.style.borderColor = 'red';
}
});
input event fires on every keystroke, while change only fires when the user leaves the field or confirms an edit.
Modern browsers include built-in form validation through HTML attributes such as required, minlength, pattern, and type. JavaScript can check or override these constraints using the checkValidity() and reportValidity() methods.
<form id="signup">
<input type="email" required>
<button>Submit</button>
</form>
<script>
let form = document.getElementById('signup');
form.addEventListener('submit', event => {
if (!form.checkValidity()) {
event.preventDefault();
form.reportValidity(); // Show browser error message
}
});
</script>
Custom validation logic can supplement or replace the built-in checks. This is useful when the rules are dynamic or complex, such as confirming password fields or verifying server data asynchronously.
form.addEventListener('submit', event => {
let password = document.querySelector('#pass').value;
let confirm = document.querySelector('#confirm').value;
if (password !== confirm) {
event.preventDefault();
alert('Passwords do not match');
}
});
By combining HTML constraints, event handling, and script-based checks, you can provide responsive and user-friendly forms that guide users toward correct input without unnecessary friction.
fetch and HTTP requests
The fetch() API provides a modern, promise-based interface for making HTTP requests in JavaScript. It replaces the older XMLHttpRequest with a cleaner syntax that integrates naturally with async and await. You can use it to retrieve or send data to servers, handle JSON APIs, or load resources dynamically.
// Basic GET request
fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Error:', error));
The fetch() function returns a Promise that resolves to a Response object. You typically call one of its methods, such as .text(), .json(), or .blob(), to extract the body content. Each of these also returns a promise, making async/await syntax especially convenient.
// Using async/await for readability
async function getData() {
try {
let response = await fetch('https://api.example.com/items');
if (!response.ok) throw new Error('Network error');
let items = await response.json();
console.log(items);
} catch (err) {
console.error(err.message);
}
}
getData();
You can configure the request method, headers, and body by passing an options object as the second argument. This allows you to send POST, PUT, or DELETE requests and to specify content types or authorization headers.
// Sending JSON data with POST
async function sendData() {
let payload = { name: 'Robin', score: 42 };
let response = await fetch('https://api.example.com/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
console.log('Status:', response.status);
}
sendData();
fetch() treats HTTP errors (like 404 or 500) as successful responses unless the network itself fails. Always check response.ok or response.status to detect these conditions.
By default, fetch() follows redirects, enforces same-origin policy rules, and rejects only on network-level errors. You can adjust behaviour with options such as mode, credentials, and cache to fine-tune how requests interact with the browser’s security and caching systems.
// Fetch with credentials and no-cache
fetch('/user/profile', {
credentials: 'include',
cache: 'no-store'
});
Access-Control-Allow-Origin. If not, the browser will block the response even if the server returns data.
For streaming or large downloads, the Response body also supports the Streams API, letting you process data incrementally. This enables efficient file transfers, progressive rendering, and real-time updates without waiting for the entire response to finish downloading.
Whether for APIs, form submissions, or data synchronization, fetch() provides a simple and consistent foundation for all HTTP communication in the browser.
localStorage and sessionStorage
The browser’s Web Storage API provides two key-value storage mechanisms; localStorage and sessionStorage. Both let JavaScript store small amounts of data directly in the browser, preserving it across page reloads without the need for cookies or a server round trip.
Data is stored as strings, indexed by key names, and can be retrieved or removed using straightforward methods. Each origin (protocol, domain, and port) gets its own isolated storage space, ensuring that sites cannot access each other’s data.
// Store data
localStorage.setItem('username', 'Robin');
// Retrieve data
let name = localStorage.getItem('username');
console.log(name); // "Robin"
// Remove data
localStorage.removeItem('username');
// Clear everything
localStorage.clear();
localStorage persists indefinitely, such that data remains even after the browser is closed and reopened. In contrast, sessionStorage lasts only for the duration of the browser tab or window, disappearing once it is closed.
// sessionStorage example
sessionStorage.setItem('session', 'active');
console.log(sessionStorage.getItem('session')); // "active"
JSON.stringify() and recover them using JSON.parse().
// Storing objects safely
let settings = { theme: 'dark', fontSize: 16 };
localStorage.setItem('settings', JSON.stringify(settings));
let restored = JSON.parse(localStorage.getItem('settings'));
console.log(restored.theme); // "dark"
Each storage area typically holds at least 5 MB per origin, though the exact limit varies by browser. Because the data is stored locally, reads and writes are fast and synchronous, meaning large operations can briefly block the main thread. For larger or asynchronous needs, consider IndexedDB instead.
localStorage or sessionStorage.
Used wisely, these mechanisms are ideal for lightweight persistence, user preferences, or cached data that improves page performance without requiring a network connection.