Week 7: DOM Manipulation & Events

INFO 153A/253A - Front-End Web Architecture

UC Berkeley School of Information

October 6, 2025

Today's Learning Objectives

  • Navigate and manipulate the DOM tree structure
  • Select elements using modern DOM methods
  • Create, modify, and remove DOM elements dynamically
  • Handle user events with event listeners
  • Understand event propagation and delegation
  • Persist data with LocalStorage
  • Build interactive web applications
Key Insight: The DOM is not your HTML - it's a live, programmable representation of your page that JavaScript can manipulate in real-time.

Part 1: Prep Work Recap

Review of DOM and Events concepts from your videos

↓ Navigate down for details

The DOM Tree Structure

window
└── document
    └── html
        ├── head
        │   └── title (text node)
        └── body
            ├── div#main
            │   ├── h1.title (text node)
            │   └── p (text node)
            └── script
  • The DOM is a tree structure where every HTML element becomes a node in the tree
  • window is the global object representing the browser window itself
  • document is a child of window and represents the entire HTML document
  • Every element has relationships: parent, children, siblings - just like a family tree
  • Text content becomes text nodes - they're part of the DOM too, not just elements
  • This structure enables traversal - you can navigate from any element to any other

DOM Selection Methods from Prep

// Single element selectors
const element = document.getElementById('myId');
const firstMatch = document.querySelector('.myClass');

// Multiple element selectors
const allDivs = document.getElementsByTagName('div');
const allCards = document.querySelectorAll('.card');
  • getElementById is the fastest selector but only works with IDs
  • querySelector uses CSS selectors making it incredibly flexible and powerful
  • querySelectorAll returns a NodeList not an array - important distinction!
  • getElementsBy* methods return live collections that update automatically
  • querySelector* returns static collections that don't update
  • Modern best practice: Use querySelector/querySelectorAll for everything

Event Listeners from Prep

// Adding an event listener
button.addEventListener('click', function() {
    console.log('Button clicked!');
});

// The event object
button.addEventListener('click', function(e) {
    console.log(e.target);  // The element that was clicked
    console.log(e.type);    // 'click'
});
  • addEventListener is the modern way to attach event handlers - never use onclick attributes
  • The first parameter is the event type without the "on" prefix
  • The callback function receives an event object with tons of useful information
  • Multiple listeners can be added to the same element for the same event
  • Event listeners are not removed when elements are removed from DOM - potential memory leak!
  • The 'this' keyword inside the handler refers to the element (unless using arrow functions)

Creating and Modifying Elements

// Creating elements
const newDiv = document.createElement('div');
newDiv.textContent = 'Hello World';
newDiv.className = 'my-class';

// Adding to DOM
parentElement.appendChild(newDiv);

// Modifying existing elements
element.innerHTML = '<strong>Bold text</strong>';
element.style.backgroundColor = 'blue';
  • createElement creates elements in memory - they don't appear until added to DOM
  • textContent is safer than innerHTML as it prevents XSS attacks
  • innerHTML parses HTML but can be dangerous with user input
  • appendChild adds to the end of parent's children
  • Elements must be added to the DOM to be visible - common beginner mistake
  • Style changes through .style use camelCase, not kebab-case

Part 2: DOM Manipulation Deep Dive

Professional techniques for DOM manipulation

↓ Navigate down for advanced patterns

Advanced Element Selection

// Complex selectors
const submitBtn = document.querySelector('form#userForm button[type="submit"]');
const activeItems = document.querySelectorAll('li.item:not(.disabled)');

// Relative selection
const card = document.querySelector('.card');
const cardTitle = card.querySelector('.title');  // Searches within card
const nextCard = card.nextElementSibling;
const parentContainer = card.closest('.container');
  • CSS selector syntax works completely - pseudo-classes, combinators, everything!
  • You can search within elements by calling querySelector on any element, not just document
  • nextElementSibling skips text nodes unlike nextSibling which includes them
  • closest() travels up the tree to find the nearest matching ancestor
  • These methods enable contextual selection - find elements relative to others
  • Pro pattern: Store parent references and search within them for better performance

DOM Traversal Mastery

// Navigate the DOM tree efficiently
const element = document.querySelector('.current');

// Parent navigation
const parent = element.parentElement;
const grandparent = element.parentElement.parentElement;

// Sibling navigation
const prevSibling = element.previousElementSibling;
const nextSibling = element.nextElementSibling;
const allSiblings = [...element.parentElement.children]
    .filter(child => child !== element);

// Children navigation
const firstChild = element.firstElementChild;
const lastChild = element.lastElementChild;
const allChildren = element.children; // HTMLCollection
const childArray = [...element.children]; // Convert to array
  • Element properties skip text nodes making traversal cleaner than node properties
  • parentElement is usually preferred over parentNode for element traversal
  • children returns HTMLCollection which is live and array-like but not an array
  • Spread operator [...collection] converts HTMLCollection to real array for array methods
  • Sibling selection often requires filtering to exclude the current element
  • Chain traversal carefully - too many .parentElement calls indicate poor structure

Creating Complex Elements

// Build a complete component
function createCard(title, content, imageUrl) {
    const card = document.createElement('article');
    card.className = 'card';

    // Use template literal for structure
    card.innerHTML = `
        <img src="${imageUrl}" alt="${title}" class="card-image">
        <div class="card-body">
            <h3 class="card-title">${title}</h3>
            <p class="card-content">${content}</p>
            <button class="card-btn" data-action="delete">Delete</button>
        </div>
    `;

    // Add event listener to button
    card.querySelector('.card-btn').addEventListener('click', handleDelete);

    return card;
}

// Add to page
const container = document.querySelector('.container');
container.appendChild(createCard('Title', 'Content', 'image.jpg'));
  • Component functions encapsulate element creation logic for reusability
  • Template literals make HTML readable while keeping it in JavaScript
  • innerHTML is acceptable here because we control all the content
  • Data attributes (data-*) store metadata for event handlers to use
  • Event listeners attached immediately ensure functionality is always present
  • Return the element for flexible usage - append, prepend, or store reference
  • This pattern scales to complex components and is similar to React components

Efficient DOM Updates

// Batch DOM updates for performance
const fragment = document.createDocumentFragment();
const list = document.querySelector('#myList');

// Build in memory
data.forEach(item => {
    const li = document.createElement('li');
    li.textContent = item.name;
    fragment.appendChild(li);  // Add to fragment, not DOM
});

// Single DOM update
list.appendChild(fragment);

// Update multiple properties efficiently
const element = document.querySelector('.target');
element.style.cssText = `
    background-color: blue;
    color: white;
    padding: 10px;
    border-radius: 5px;
`;
  • DocumentFragment is a lightweight container that doesn't trigger reflows
  • Build complex structures in memory then add to DOM once - massive performance gain
  • Each DOM update triggers reflow/repaint which is expensive
  • cssText sets multiple styles at once reducing reflow to single operation
  • This matters for lists - adding 100 items individually is 100x slower
  • Modern frameworks do this automatically but you need to know for vanilla JS

Class and Attribute Manipulation

// Modern class manipulation
const element = document.querySelector('.box');

// Add/remove/toggle classes
element.classList.add('active', 'highlighted');
element.classList.remove('disabled');
element.classList.toggle('visible'); // Add if missing, remove if present

// Conditional class
element.classList.toggle('error', hasError); // Second param is condition

// Check for class
if (element.classList.contains('active')) {
    // Do something
}

// Attribute manipulation
element.setAttribute('data-id', '123');
element.getAttribute('data-id'); // '123'
element.removeAttribute('disabled');
element.hasAttribute('required'); // true/false

// Data attributes shortcut
element.dataset.id = '456'; // Sets data-id="456"
const id = element.dataset.id; // Gets data-id value
  • classList API is far superior to className string manipulation
  • Multiple classes can be added/removed in a single call
  • toggle() with condition is perfect for state-based styling
  • Data attributes are the standard way to store element metadata
  • dataset provides convenient access to data-* attributes with camelCase conversion
  • Attributes vs properties: Attributes are in HTML, properties are in DOM object

Part 3: Event Handling Mastery

Making web pages interactive

↓ Navigate down for event expertise

The Event Object Deep Dive

// The event object contains everything you need
button.addEventListener('click', function(e) {
    // Event properties
    console.log(e.type);           // 'click'
    console.log(e.target);         // Element that triggered event
    console.log(e.currentTarget);  // Element with listener (this)

    // Mouse event properties
    console.log(e.clientX, e.clientY);  // Mouse position

    // Keyboard event properties (for keydown/keyup)
    console.log(e.key);     // 'Enter', 'a', 'ArrowUp', etc.
    console.log(e.ctrlKey, e.shiftKey, e.altKey); // Modifier keys

    // Prevent default behavior
    e.preventDefault();  // Stop normal action
    e.stopPropagation(); // Stop bubbling
});
  • The event object is your swiss army knife containing all event information
  • target vs currentTarget is crucial: target is what was clicked, currentTarget is what has the listener
  • Mouse coordinates help with drag-and-drop, drawing, and tooltips
  • Key property is modern standard replacing keyCode which is deprecated
  • Modifier keys enable shortcuts like Ctrl+S or Shift+Click
  • preventDefault stops default behavior like form submission or link navigation

Common Event Types

// Mouse events
element.addEventListener('click', handleClick);
element.addEventListener('dblclick', handleDoubleClick);
element.addEventListener('mouseenter', handleMouseEnter); // Doesn't bubble
element.addEventListener('mouseleave', handleMouseLeave); // Doesn't bubble
element.addEventListener('mouseover', handleMouseOver);   // Bubbles
element.addEventListener('mouseout', handleMouseOut);     // Bubbles

// Keyboard events
input.addEventListener('keydown', handleKeyDown);   // Key pressed down
input.addEventListener('keyup', handleKeyUp);       // Key released
input.addEventListener('keypress', handleKeyPress); // Deprecated!

// Form events
form.addEventListener('submit', handleSubmit);
input.addEventListener('input', handleInput);   // Every change
input.addEventListener('change', handleChange); // On blur after change
input.addEventListener('focus', handleFocus);
input.addEventListener('blur', handleBlur);

// Document/Window events
window.addEventListener('load', handleLoad);         // Everything loaded
document.addEventListener('DOMContentLoaded', handleReady); // DOM ready
window.addEventListener('resize', handleResize);
window.addEventListener('scroll', handleScroll);
  • Mouse enter/leave don't bubble making them perfect for hover effects
  • Input fires on every change while change fires when field loses focus
  • DOMContentLoaded is usually preferred over load as it fires earlier
  • Scroll and resize events fire rapidly and should be throttled
  • Submit event on forms is better than click on submit button
  • Focus/blur are essential for form validation and accessibility

Event Handler Patterns

// Pattern 1: Named functions (recommended)
function handleClick(e) {
    console.log('Clicked:', e.target);
}
button.addEventListener('click', handleClick);
button.removeEventListener('click', handleClick); // Can remove!

// Pattern 2: Arrow functions (careful with 'this')
button.addEventListener('click', (e) => {
    console.log(this); // Window, not button!
});

// Pattern 3: Event handler object
const handler = {
    handleEvent(e) {
        switch(e.type) {
            case 'click':
                this.onClick(e);
                break;
            case 'mouseover':
                this.onHover(e);
                break;
        }
    },
    onClick(e) { /* ... */ },
    onHover(e) { /* ... */ }
};
button.addEventListener('click', handler);
button.addEventListener('mouseover', handler);
  • Named functions can be removed - anonymous functions cannot
  • Arrow functions don't bind 'this' which can be good or bad
  • Handler objects allow organization of related event handlers
  • Memory leaks occur when listeners aren't removed from deleted elements
  • One handler for multiple events reduces code duplication
  • Professional code uses named functions for clarity and testability

Form Handling Best Practices

// Professional form handling
const form = document.querySelector('#userForm');

form.addEventListener('submit', async (e) => {
    e.preventDefault(); // Stop page reload

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

    // Validation
    if (!data.email || !data.email.includes('@')) {
        showError('Invalid email');
        return;
    }

    // Disable form during submission
    const submitBtn = form.querySelector('[type="submit"]');
    submitBtn.disabled = true;
    submitBtn.textContent = 'Submitting...';

    try {
        // Submit data
        await submitToServer(data);
        form.reset(); // Clear form
        showSuccess('Form submitted!');
    } catch (error) {
        showError('Submission failed');
    } finally {
        submitBtn.disabled = false;
        submitBtn.textContent = 'Submit';
    }
});
  • Always prevent default on forms to handle submission with JavaScript
  • FormData API extracts all form values automatically based on name attributes
  • Object.fromEntries converts FormData to a plain object for easy use
  • Disable submit button during submission to prevent double-submits
  • Visual feedback is crucial - show loading states and results
  • Try/catch/finally ensures button is re-enabled even on error
  • form.reset() clears all fields after successful submission

Custom Events

// Create and dispatch custom events
const customEvent = new CustomEvent('taskComplete', {
    detail: {
        taskId: 123,
        completedAt: Date.now()
    },
    bubbles: true,
    cancelable: true
});

// Dispatch the event
element.dispatchEvent(customEvent);

// Listen for custom events
document.addEventListener('taskComplete', (e) => {
    console.log('Task completed:', e.detail.taskId);
    updateTaskCount();
});

// Real-world pattern: Component communication
class TodoItem {
    complete() {
        this.element.dispatchEvent(new CustomEvent('todo:complete', {
            detail: { id: this.id },
            bubbles: true
        }));
    }
}

// Parent listens for all todo events
todoList.addEventListener('todo:complete', (e) => {
    updateStats();
    saveToLocalStorage();
});
  • Custom events enable component communication without tight coupling
  • detail property carries custom data with the event
  • Bubbling allows parent containers to catch events from all children
  • Naming convention with colons (todo:complete) prevents conflicts
  • This is the observer pattern implemented with DOM events
  • Frameworks use this internally for component communication

Part 4: Event Flow & Delegation

Advanced event patterns for scalable applications

↓ Navigate down for professional patterns

Event Propagation: Capture & Bubble

Capture Phase ↓
Target Phase
Bubble Phase ↑
// Event propagation demonstration
const outer = document.querySelector('.outer');
const inner = document.querySelector('.inner');
const button = document.querySelector('.button');

// Capture phase (rarely used)
outer.addEventListener('click', (e) => {
    console.log('Outer capture');
}, true); // Third parameter true = capture

// Bubble phase (default)
outer.addEventListener('click', (e) => {
    console.log('Outer bubble');
}); // No third parameter = bubble

inner.addEventListener('click', (e) => {
    console.log('Inner bubble');
    e.stopPropagation(); // Stops here
});

button.addEventListener('click', (e) => {
    console.log('Button clicked');
});

// Click button logs: "Outer capture" → "Button clicked" → "Inner bubble"
// Outer bubble never fires due to stopPropagation
  • Events flow in three phases: capture (down), target, bubble (up)
  • Capture phase happens first traveling from document to target
  • Bubble phase happens last traveling from target back to document
  • Most events bubble by default but some don't (focus, blur, mouseenter, mouseleave)

Event Delegation Pattern

// Without delegation: Bad for dynamic content
const buttons = document.querySelectorAll('.delete-btn');
buttons.forEach(btn => {
    btn.addEventListener('click', handleDelete);
}); // Doesn't work for new buttons!

// With delegation: Perfect for dynamic content
const todoList = document.querySelector('#todoList');

todoList.addEventListener('click', (e) => {
    // Check what was clicked
    if (e.target.classList.contains('delete-btn')) {
        const todoItem = e.target.closest('.todo-item');
        todoItem.remove();
    }

    if (e.target.classList.contains('edit-btn')) {
        const todoItem = e.target.closest('.todo-item');
        editTodo(todoItem);
    }

    if (e.target.type === 'checkbox') {
        const todoItem = e.target.closest('.todo-item');
        toggleComplete(todoItem);
    }
});

// Add new items without adding listeners
function addTodo(text) {
    const html = `
        <li class="todo-item">
            <input type="checkbox">
            <span>${text}</span>
            <button class="edit-btn">Edit</button>
            <button class="delete-btn">Delete</button>
        </li>
    `;
    todoList.insertAdjacentHTML('beforeend', html);
    // No need to add event listeners!
}
  • One listener handles all child events instead of many listeners
  • Works automatically for new elements added after page load
  • Dramatically improves performance with large lists
  • e.target tells you what was actually clicked within the container
  • closest() finds the parent component from the clicked element
  • This pattern scales infinitely - 1 listener or 10,000 items
  • Memory efficient: Only one function in memory regardless of items

Advanced Delegation Patterns

// Delegation with data attributes
document.addEventListener('click', (e) => {
    const action = e.target.dataset.action;
    if (!action) return;

    const actions = {
        delete: () => {
            const id = e.target.dataset.id;
            deleteItem(id);
        },
        edit: () => {
            const id = e.target.dataset.id;
            openEditModal(id);
        },
        toggle: () => {
            e.target.closest('.panel').classList.toggle('expanded');
        }
    };

    if (actions[action]) {
        e.preventDefault();
        actions[action]();
    }
});

// HTML uses data attributes
// <button data-action="delete" data-id="123">Delete</button>
// <button data-action="edit" data-id="123">Edit</button>
// <button data-action="toggle">Toggle Panel</button>
  • Data attributes define behavior without JavaScript in HTML
  • Single listener on document handles entire application
  • Actions object maps data-action values to functions
  • Completely decoupled: HTML doesn't know about JavaScript implementation
  • Easy to extend: Just add new action to the object
  • This scales to entire SPAs with proper organization

Performance Optimization

// Throttle scroll events
let scrollTimeout;
window.addEventListener('scroll', () => {
    if (scrollTimeout) return;

    scrollTimeout = setTimeout(() => {
        scrollTimeout = null;
        handleScroll();
    }, 100); // Max once per 100ms
});

// Debounce input events
let inputTimeout;
searchInput.addEventListener('input', (e) => {
    clearTimeout(inputTimeout);
    inputTimeout = setTimeout(() => {
        performSearch(e.target.value);
    }, 300); // Wait 300ms after typing stops
});

// Passive listeners for better scroll performance
document.addEventListener('touchstart', handleTouch, { passive: true });

// Once option for one-time events
button.addEventListener('click', handleClick, { once: true });
  • Throttling limits function calls to maximum frequency
  • Debouncing waits for pause before executing function
  • Scroll/resize fire 60+ times per second without throttling
  • Passive listeners improve performance by promising not to preventDefault
  • Once option automatically removes listener after first trigger
  • These patterns prevent janky UIs and improve battery life

Part 5: LocalStorage & Persistence

Making data survive page refreshes

↓ Navigate down for storage patterns

LocalStorage Fundamentals

// Basic LocalStorage operations
// Set item (always strings!)
localStorage.setItem('username', 'john');
localStorage.setItem('theme', 'dark');

// Get item
const username = localStorage.getItem('username'); // 'john'
const missing = localStorage.getItem('nonexistent'); // null

// Remove item
localStorage.removeItem('username');

// Clear everything
localStorage.clear();

// Working with objects/arrays
const user = { name: 'John', age: 30 };
localStorage.setItem('user', JSON.stringify(user));

const savedUser = JSON.parse(localStorage.getItem('user'));
console.log(savedUser.name); // 'John'

// Check if LocalStorage is available
if (typeof(Storage) !== "undefined") {
    // LocalStorage is supported
} else {
    // No web storage support
}
  • LocalStorage only stores strings - always stringify objects/arrays
  • Data persists until explicitly cleared unlike sessionStorage
  • 5-10MB limit per domain depending on browser
  • Synchronous API can block UI with large data
  • Same-origin policy applies - can't access other domains' storage
  • Perfect for user preferences, draft data, and app state

Real-World Storage Patterns

// Storage wrapper with error handling
class Storage {
    static get(key, defaultValue = null) {
        try {
            const item = localStorage.getItem(key);
            return item ? JSON.parse(item) : defaultValue;
        } catch (e) {
            console.error('Storage get error:', e);
            return defaultValue;
        }
    }

    static set(key, value) {
        try {
            localStorage.setItem(key, JSON.stringify(value));
            return true;
        } catch (e) {
            console.error('Storage set error:', e);
            return false;
        }
    }

    static remove(key) {
        localStorage.removeItem(key);
    }
}

// Usage
const todos = Storage.get('todos', []); // Default to empty array
Storage.set('todos', [...todos, newTodo]);

// Auto-save form drafts
const form = document.querySelector('#myForm');
const DRAFT_KEY = 'formDraft';

// Save on input
form.addEventListener('input', () => {
    const formData = new FormData(form);
    const draft = Object.fromEntries(formData);
    Storage.set(DRAFT_KEY, draft);
});

// Restore on load
window.addEventListener('DOMContentLoaded', () => {
    const draft = Storage.get(DRAFT_KEY);
    if (draft) {
        Object.entries(draft).forEach(([name, value]) => {
            const field = form.elements[name];
            if (field) field.value = value;
        });
    }
});
  • Wrapper class adds safety with try/catch and default values
  • Always provide defaults when getting data that might not exist
  • Auto-save prevents data loss from accidental refreshes
  • FormData integrates perfectly with storage for form drafts
  • This pattern has saved countless hours of user frustration
  • Clear drafts after successful submission to avoid confusion

Complete Todo App Example

// Full todo app with localStorage
class TodoApp {
    constructor() {
        this.todos = Storage.get('todos', []);
        this.init();
    }

    init() {
        this.renderTodos();
        this.attachListeners();
    }

    attachListeners() {
        // Single delegated listener
        document.querySelector('#todoList').addEventListener('click', (e) => {
            const id = e.target.dataset.id;

            if (e.target.classList.contains('delete-btn')) {
                this.deleteTodo(id);
            } else if (e.target.type === 'checkbox') {
                this.toggleTodo(id);
            }
        });

        // Form submission
        document.querySelector('#todoForm').addEventListener('submit', (e) => {
            e.preventDefault();
            const input = e.target.elements.todoText;
            this.addTodo(input.value);
            input.value = '';
        });
    }

    addTodo(text) {
        const todo = {
            id: Date.now().toString(),
            text,
            completed: false
        };
        this.todos.push(todo);
        this.save();
        this.renderTodos();
    }

    deleteTodo(id) {
        this.todos = this.todos.filter(t => t.id !== id);
        this.save();
        this.renderTodos();
    }

    toggleTodo(id) {
        const todo = this.todos.find(t => t.id === id);
        if (todo) {
            todo.completed = !todo.completed;
            this.save();
            this.renderTodos();
        }
    }

    save() {
        Storage.set('todos', this.todos);
    }

    renderTodos() {
        const html = this.todos.map(todo => `
            <li class="todo-item ${todo.completed ? 'completed' : ''}">
                <input type="checkbox" data-id="${todo.id}"
                       ${todo.completed ? 'checked' : ''}>
                <span>${todo.text}</span>
                <button class="delete-btn" data-id="${todo.id}">×</button>
            </li>
        `).join('');

        document.querySelector('#todoList').innerHTML = html;
    }
}

// Initialize app
new TodoApp();
  • Complete app in ~60 lines with full persistence
  • Class structure organizes code into logical methods
  • Single source of truth: todos array drives everything
  • Save after every change ensures data is never lost
  • Re-render after changes keeps UI in sync with data
  • This architecture scales to much larger applications
  • Same patterns used in React/Vue but without the framework

Key Takeaways

DOM Manipulation

  • querySelector for everything
  • Build in memory, insert once
  • classList API for styling
  • Think in components

Events

  • addEventListener is the way
  • Event object has everything
  • Delegation for dynamic content
  • Throttle/debounce for performance

Best Practices

  • Named functions over anonymous
  • Data attributes for metadata
  • preventDefault for form handling
  • Custom events for components

Storage

  • Always stringify objects
  • Provide default values
  • Auto-save for better UX
  • Clear storage appropriately
Remember: The DOM and events are your tools for creating amazing user experiences. Master these, and you can build anything!