jsguides

Working DOM in JavaScript: Select, Create, and Modify

Working DOM manipulation is the foundation of every interactive web page. The Document Object Model represents HTML as a tree of objects that JavaScript can read, modify, and respond to. This tutorial walks you through selecting elements, creating new content, handling events, and building interactive experiences from scratch.

What you’ll learn

By the end of this tutorial, you will know how to find any element on a page, create and insert new nodes, modify attributes and styles, listen for user events, and remove elements cleanly. You will also learn event delegation and form handling patterns that scale to real applications.

Understanding the DOM

When a browser loads an HTML page, it parses the markup and builds a tree structure called the DOM. Each HTML tag becomes a node in this tree, with parent-child relationships reflecting the nesting in your HTML.

// The document object is your entry point to the DOM
console.log(document.title);        // Page title
console.log(document.body);          // The <body> element
console.log(document.location.href); // Current URL

Every element in the DOM is an object with properties you can read and methods you can call. Changing these properties updates the page in real-time. The document itself is the root; from it you can reach every other node on the page.

Selecting Elements

Before you can work with elements, you need to find them. JavaScript provides several methods for selecting elements from the DOM.

getElementById and querySelector

The fastest way to get a single element is by its ID. getElementById is available on document and returns the one element with a matching id attribute, or null if nothing matches:

const header = document.getElementById('main-header');
console.log(header.textContent);

For more flexible selection, querySelector uses CSS selectors. It returns the first element that matches, which is useful when you need to combine selectors like .sidebar .active or input[type="email"]. Unlike getElementById, querySelector can be called on any element to search only within its subtree:

// Get the first matching element
const firstButton = document.querySelector('.btn');

// Get all matching elements
const allButtons = document.querySelectorAll('.btn');

querySelectorAll returns a NodeList, which you can loop through. A NodeList is not a live collection; it is a static snapshot, so adding new matching elements to the page after the call does not change the list. This differs from methods like getElementsByClassName, which return live HTMLCollections that update automatically when the DOM changes:

document.querySelectorAll('.menu-item').forEach(item => {
  console.log(item.textContent);
});

Use querySelectorAll with forEach when you need to process every match in bulk: applying a class, extracting text, or attaching listeners. For single-element lookups, stick with querySelector; it stops after the first match and is noticeably faster on large pages. This same principle applies to form fields, where the browser provides an even more targeted API.

Selecting form elements

Forms have special methods for accessing inputs. The form.elements collection lets you reference controls by their name attribute, which is simpler than querying the whole document for each field. This approach also handles radio button groups and checkboxes correctly without extra logic. Once you have references to the elements you need, the next step is to build entirely new content and place it on the page:

const form = document.getElementById('my-form');
const username = form.elements.username;
const password = form.elements.password;

The form.elements API is especially useful when a form has many fields — you can loop through the collection rather than querying each input individually. Once you can find elements on the page, the next natural step is to create completely new ones and insert them where you need them.

Creating and inserting elements

Building dynamic pages requires creating new elements and placing them in the DOM.

Creating Elements

Use document.createElement() to make new nodes:

const newParagraph = document.createElement('p');
newParagraph.textContent = 'Hello, world!';
newParagraph.className = 'highlight';

Creating an element does not place it on the page. The new node exists only in memory until you insert it somewhere. This separation is useful: you can configure the element fully before it becomes visible, avoiding flicker or partial renders.

Inserting Elements

Once you have an element, insert it with one of several methods. appendChild is the classic approach, adding the node as the last child of a parent. Modern browsers also support prepend, before, and after, which give you finer control over positioning:

const container = document.getElementById('container');

// Add as the last child
container.appendChild(newParagraph);

// Insert before a specific child
const reference = container.querySelector('.some-element');
container.insertBefore(newParagraph, reference);

// Modern insertion methods (more flexible)
container.prepend(newParagraph);           // Insert at start
container.before(newParagraph);            // Insert before container
container.after(newParagraph);             // Insert after container

insertBefore needs a reference node that is already in the DOM. If that reference is null, the behavior is the same as appendChild. The newer prepend/before/after methods are more intuitive to read but have slightly different browser support profiles for very old environments.

Template Literals for HTML

For larger chunks of HTML, template literals work well. Instead of building element trees manually, you can write the HTML structure as a string and assign it to innerHTML. Be aware that innerHTML re-parses the entire content. For performance-sensitive updates on deeply nested containers, consider building elements individually or using insertAdjacentHTML:

const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
  <h2>${title}</h2>
  <p>${description}</p>
  <button>Click me</button>
`;
document.body.appendChild(card);

Modifying Elements

Once you have a reference to an element, you can change its properties. Every attribute, class, style, and text node is editable through the DOM API. The key distinction is between textContent (plain text, safe from injection) and innerHTML (parsed HTML, useful for rich content but requires caution with user input).

Changing Content

const element = document.getElementById('content');

// Replace all content
element.textContent = 'New text content';

// Or HTML content
element.innerHTML = '<strong>Bold</strong> and normal text';

Use textContent when you only need to set visible text; it is faster and automatically escapes any HTML tags in the string. Use innerHTML when you need the browser to parse markup, but avoid passing unsanitized user input through it.

Changing attributes and properties

Attributes live in the HTML markup; properties live on the DOM object. For standard attributes like href, id, and target, the property form is usually shorter and more readable. For custom data-* attributes, use the dataset API which converts hyphenated names to camelCase:

const link = document.querySelector('a');

// Set attributes
link.setAttribute('href', 'https://example.com');
link.setAttribute('target', '_blank');

// Or use property syntax
link.href = 'https://example.com';
link.target = '_blank';

// Check attributes
if (link.hasAttribute('data-id')) {
  console.log(link.dataset.id);
}

The setAttribute method always takes string values, while property assignment preserves the JavaScript type. For boolean attributes like disabled, setting the property to true or false is clearer than calling setAttribute('disabled', '') and removeAttribute('disabled').

Working with Classes

The classList API is the safest way to manage CSS classes. It avoids accidentally overwriting other classes on the same element and provides readable methods for common operations:

const box = document.getElementById('box');

// Add classes
box.classList.add('active', 'highlighted');

// Remove classes
box.classList.remove('hidden');

// Toggle a class
box.classList.toggle('expanded');

// Check for class
if (box.classList.contains('active')) {
  console.log('Box is active');
}

Calling classList.toggle() without a second argument flips the class on or off. Pass true or false as the second argument to force a specific state, which is useful when the toggle depends on external logic.

Styling

The style property sets inline styles directly on the element. These override stylesheet rules and are best for dynamic values that change at runtime, like a progress bar width or a computed color. For reading the final computed style, including values from CSS, use getComputedStyle:

const element = document.getElementById('my-element');

// Direct style changes
element.style.color = 'blue';
element.style.backgroundColor = '#f0f0f0';
element.style.display = 'none';

// For complex styles, use getComputedStyle
const styles = getComputedStyle(element);
console.log(styles.fontSize);

CSS property names use camelCase in JavaScript: background-color in CSS becomes backgroundColor on element.style. The getComputedStyle return value is read-only and reflects the actual rendered value after all stylesheets, inheritance, and browser defaults are applied.

Removing Elements

Clean up elements you no longer need:

const element = document.getElementById('to-remove');

// Remove from DOM
element.remove();

// Or the older method
element.parentNode.removeChild(element);

Handling Events

Events make pages interactive. JavaScript can respond to clicks, keypresses, form submissions, and more.

Adding event listeners

The addEventListener method attaches handlers to elements. The first argument is the event name (without the “on” prefix), and the second is the callback function that receives an Event object:

const button = document.getElementById('my-button');

button.addEventListener('click', function(event) {
  console.log('Button clicked!');
  console.log(event.target); // The element that was clicked
});

You can also use arrow functions, which are more concise and avoid rebinding this. The event object provides details like mouse coordinates (clientX, clientY), the target element, and methods like preventDefault() to suppress the browser’s default behavior. Named functions are useful when you plan to remove the listener later, while arrow functions work well for one-time setup or quick prototypes:

button.addEventListener('click', (e) => {
  e.preventDefault(); // Prevent default behavior
  console.log('Clicked at:', e.clientX, e.clientY);
});

Common Events

JavaScript surfaces dozens of event types. The most common ones fall into a few categories: mouse, keyboard, form, and document lifecycle. Mouse events track pointer interaction, keyboard events capture key presses, form events fire on submission and input changes, and document events signal page readiness:

// Mouse events
element.addEventListener('click', handler);
element.addEventListener('mouseenter', handler);
element.addEventListener('mouseleave', handler);

// Keyboard events
document.addEventListener('keydown', (e) => {
  console.log('Key pressed:', e.key);
});
document.addEventListener('keyup', handler);

// Form events
form.addEventListener('submit', (e) => {
  e.preventDefault(); // Stop page reload
  // Process form data
});

input.addEventListener('input', (e) => {
  console.log('Current value:', e.target.value);
});

input.addEventListener('change', (e) => {
  console.log('Final value:', e.target.value);
});

// Document events
document.addEventListener('DOMContentLoaded', () => {
  console.log('DOM is ready!');
});

window.addEventListener('load', () => {
  console.log('Page fully loaded');
});

The input event fires on every keystroke, while change fires only when the user commits the value (blur for text fields, selection for selects). Use input for live validation or character counting; use change for final-value processing.

Event Delegation

Instead of adding listeners to each child, attach one to the parent. The event bubbles up from the clicked element to its ancestors, so the parent can check e.target to determine which child was actually clicked. This works for dynamically added elements because the listener lives on the stable parent, and it keeps the page performant when you have many similar items; one listener instead of hundreds:

const list = document.getElementById('my-list');

list.addEventListener('click', (e) => {
  // Check if a list item was clicked
  if (e.target.tagName === 'LI') {
    console.log('List item clicked:', e.target.textContent);
  }
});

This approach works even for dynamically added elements. For complex delegation, check e.target.closest('.selector') instead of tagName; it walks up the DOM tree and matches the first ancestor that fits the selector, which is more reliable when the clicked element is nested inside the target. Delegation is the standard pattern for lists, tables, and any container that renders children from data.

Removing event listeners

To remove a listener, you need a reference to the exact same function that was added. Anonymous functions and arrow functions created inline cannot be removed later because each call to addEventListener with an inline function creates a new reference. If you plan to remove a listener later, always store the handler in a named variable or function:

function handleClick() {
  console.log('Clicked!');
}

button.addEventListener('click', handleClick);
// Later, remove it
button.removeEventListener('click', handleClick);

Leaking listeners is a common source of memory problems in long-lived pages. If a component adds listeners and the component gets removed from the DOM without cleaning up those listeners, the handler closures keep references to DOM elements that should have been garbage collected. Always pair addEventListener calls with a corresponding teardown.

Working with Forms

Forms are central to most web applications. The FormData API gives you a clean way to collect all field values at once, and Object.fromEntries converts the result into a plain object for easy processing:

const form = document.getElementById('registration-form');

form.addEventListener('submit', (e) => {
  e.preventDefault();

  // Get form data
  const formData = new FormData(form);
  const data = Object.fromEntries(formData);

  console.log('Form data:', data);

  // Or access individual fields
  const email = form.elements.email.value;
  const agreed = form.elements.terms.checked;
});

Input Validation

Client-side validation gives users immediate feedback before the form is submitted. The setCustomValidity method lets you define your own validation messages that the browser’s built-in validation UI displays. For simple checks like minimum length or required fields, the HTML required and minlength attributes are often enough, but custom validation handles rules that go beyond what attributes can express:

const input = document.getElementById('username');

input.addEventListener('blur', () => {
  if (input.value.length < 3) {
    input.setCustomValidity('Username must be at least 3 characters');
    input.reportValidity();
  } else {
    input.setCustomValidity('');
  }
});

The blur event fires when the user leaves the field, which is a natural moment to validate. Calling reportValidity() triggers the browser’s native error display without submitting the form. Always reset with setCustomValidity('') when validation passes, otherwise the field stays in an error state permanently.

Practical example: task list

Let’s build a simple task list to apply what we’ve learned:

const taskForm = document.getElementById('task-form');
const taskInput = document.getElementById('task-input');
const taskList = document.getElementById('task-list');

taskForm.addEventListener('submit', (e) => {
  e.preventDefault();

  const text = taskInput.value.trim();
  if (!text) return;

  // Create task item
  const li = document.createElement('li');
  li.innerHTML = `
    <span>${text}</span>
    <button class="delete-btn">×</button>
  `;

  // Delete handler
  li.querySelector('.delete-btn').addEventListener('click', () => {
    li.remove();
  });

  taskList.appendChild(li);
  taskInput.value = '';
});

Summary

Working With DOM Structure

The DOM becomes much easier to use when you think in terms of structure rather than raw nodes. A page is not just a pile of elements. It is a tree with relationships, and those relationships shape how you should edit it. If a component is rebuilt often, keep the elements it depends on grouped together. If something is repeated many times, consider creating it from a template or a small helper so the structure stays consistent. That makes the markup easier to reason about and reduces the odds of one repeated item drifting away from the others.

This way of thinking also helps with state. A DOM node can reflect current state in its text, attributes, classes, and dataset values. If one part of the app writes those values and another part reads them, the shape needs to stay predictable. Clear naming on IDs, classes, and data attributes gives you that predictability. It also makes selectors easier to read, which matters once a page has more than a few interactive pieces.

Events And Ownership

Events are most reliable when the code that creates an element also knows who listens to it. That does not mean every button needs its own large handler. It means the code should be clear about which element owns the interaction and which part of the app responds to it. Event delegation helps when many similar elements need the same logic, because the parent can listen once and act based on the target that fired the event. That keeps the page lighter and makes dynamic content easier to manage.

It is also wise to think about cleanup. If a component adds listeners, creates timers, or inserts nodes, it should also know how to remove or reset them later. In long-lived pages, stale listeners can create bugs that are hard to trace because they fire after the visible UI is gone. A clean setup and teardown story makes the DOM code easier to trust and easier to test.

Building small UI pieces

The DOM is at its best when you use it to build small, focused pieces of interface. A tiny form helper, a live counter, or a simple notification area can all be built with direct DOM manipulation and clear event handling. Those pieces do not need a framework to be understandable. They just need a clear flow from input to state to rendered output. That flow is the real skill behind DOM work.

When the UI grows larger, the same ideas still apply. Create helpers for repeated behavior, keep rendering code close to the elements it controls, and avoid spreading the same state across many places. The result is code that feels calmer to change. You do not need to remember every detail of the page at once because the structure already tells you where things belong.

The DOM is your interface for building interactive web pages. Key takeaways:

  • Use getElementById and querySelector to find elements
  • Create elements with document.createElement() and insert with appendChild, prepend, or insertBefore
  • Modify content with textContent and innerHTML
  • Handle events with addEventListener
  • Use event delegation for dynamic elements

With these fundamentals, you can build anything from simple interactive widgets to complex single-page applications.

Final DOM Note

The DOM becomes much easier to work with when each element has one clear role. A button should trigger one action, a container should hold one group of related nodes, and a helper should own one patch of UI. That simple structure keeps the page easier to understand when you come back to it later.

If a component starts to mix layout, data, and event logic all in one place, split it before it grows harder to read. Smaller DOM helpers are easier to test and easier to move when the page changes.

Next steps

Now that you can select, create, and manipulate DOM elements, try building a small interactive widget like a tab switcher, an accordion, or a live search filter. The same patterns scale to larger applications because every UI component is ultimately a tree of elements with event handlers attached. For more advanced topics, explore custom events for component communication and the Shadow DOM for encapsulated styles.

See Also