0: Vanilla JS Crash Course
Prerequisites for React — the DOM API is how JavaScript talks to HTML natively. React abstracts all of this, but understanding the raw browser APIs reveals why React’s model exists, exposes the failure modes React prevents, and prepares you for general JavaScript interview questions and performance debugging.
1. Selecting Elements
Three selectors, and the differences between them matter in production:
// By ID — O(1) hash lookup, fastest
const header = document.getElementById('header') // returns HTMLElement | null
// By CSS selector — first match only
const button = document.querySelector<HTMLButtonElement>('.submit-btn') // returns T | null
// By CSS selector — ALL matches
const items = document.querySelectorAll<HTMLLIElement>('.list-item') // returns NodeList<T>The null problem. Both getElementById and querySelector return null if nothing matches. In TypeScript, the return type of querySelector is Element | null — you must narrow it before use. The type argument (querySelector<HTMLButtonElement>) is a cast, not a guarantee; TypeScript trusts you that the selector actually returns that element type.
const btn = document.querySelector<HTMLButtonElement>('#submit')
if (!btn) throw new Error('#submit not found')
btn.disabled = true // safe — btn is HTMLButtonElement hereLive NodeLists vs static NodeLists. This is the subtlety most tutorials skip. querySelectorAll returns a static NodeList — a snapshot at query time. getElementsByClassName and getElementsByTagName return a live HTMLCollection that updates as the DOM changes. This distinction causes bugs:
const live = document.getElementsByClassName('item') // live — reflects future DOM changes
const static_ = document.querySelectorAll('.item') // static — snapshot
document.body.appendChild(Object.assign(document.createElement('div'), { className: 'item' }))
console.log(live.length) // already reflects the new element
console.log(static_.length) // still the old countPrefer querySelectorAll unless you explicitly need live updates. Iterating a live collection while mutating the DOM is a classic source of infinite loops.
Exercise: Fill in the blanks.
// Select the element with id="app", asserting it exists
const app = document.getElementById('app')!
// Select the first <p> tag inside a div with class "content"
const paragraph = document.querySelector<HTMLParagraphElement>('.content p')
// Select ALL elements with class "card" as a typed NodeList
const cards = document.querySelectorAll<HTMLDivElement>('.card')2. Creating and Appending Elements
The naive approach works but has a performance cliff:
// Naive — triggers a reflow for each appendChild
const ul = document.querySelector<HTMLUListElement>('#list')!
const items = ['Apple', 'Banana', 'Cherry']
items.forEach(text => {
const li = document.createElement('li')
li.textContent = text
ul.appendChild(li) // forces layout recalculation each time
})Use DocumentFragment for batch inserts. A DocumentFragment is an off-screen document node — mutations to it don’t trigger reflows. Only the final appendChild triggers a single layout pass:
const fragment = document.createDocumentFragment()
items.forEach(text => {
const li = document.createElement('li')
li.textContent = text
fragment.appendChild(li) // no reflow
})
ul.appendChild(fragment) // one reflowFor large lists (100+ items), the difference is measurable. The browser’s layout engine has to recalculate styles, geometry, and potentially trigger a repaint for every DOM insertion that lands in the live document.
innerHTML vs textContent — the XSS trap. Setting innerHTML parses the string as HTML, which means it executes any embedded script:
const userInput = '<img src=x onerror="alert(document.cookie)">'
el.innerHTML = userInput // executes the onerror handler — XSS
el.textContent = userInput // safe — renders as literal text, no parsingNever set innerHTML from untrusted input. If you need to insert HTML you control, insertAdjacentHTML is safer to reason about because its position argument makes the intent explicit:
el.insertAdjacentHTML('beforeend', '<span class="badge">New</span>')
// positions: 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend'Exercise: Fill in the blanks using DocumentFragment.
const btn = document.createElement('button')
btn.textContent = 'Click Me'
btn.className = 'primary'
const fragment = document.createDocumentFragment()
fragment.appendChild(btn)
document.getElementById('toolbar')!.appendChild(fragment)3. Event Listeners
The API looks simple but has three production failure modes: memory leaks, missing cleanup, and incorrect this binding.
const button = document.querySelector<HTMLButtonElement>('#my-btn')!
// Basic
button.addEventListener('click', (e: MouseEvent) => {
console.log(e.target) // the element that received the event
console.log(e.currentTarget) // the element the listener is attached to
})Bubbling and capturing. Events bubble up the DOM tree by default: a click on a <button> inside a <div> triggers listeners on the button, then the div, then body, then document. The third argument to addEventListener controls capture phase (top-down instead of bottom-up):
document.addEventListener('click', handler, { capture: true }) // fires during capture, before the target
document.addEventListener('click', handler, { capture: false }) // default — fires during bubbleEvent delegation — the pattern you’ll use constantly. Instead of attaching a listener to every item in a list, attach one listener to the parent and check e.target. This is cheaper to set up, survives DOM mutations, and avoids the memory leak of orphaned listeners:
// Bad — attaches N listeners, leaks if items are removed without cleanup
document.querySelectorAll<HTMLLIElement>('.item').forEach(item => {
item.addEventListener('click', handleClick)
})
// Good — one listener, works for items added dynamically
const list = document.querySelector<HTMLUListElement>('#list')!
list.addEventListener('click', (e: MouseEvent) => {
const item = (e.target as Element).closest<HTMLLIElement>('.item')
if (!item) return // click was on the list but not an item
handleClick(item)
})closest() walks up from the clicked target to find the nearest ancestor matching the selector — essential for delegation when the click target might be a child element (like an icon inside a button).
Memory leaks from event listeners. The most common vanilla JS memory leak: you attach a listener to a long-lived element (document, window) from a short-lived context, and never remove it. The short-lived context can’t be garbage collected because the listener holds a reference to it.
The classic fix is to capture a reference to the handler and call removeEventListener with the same function reference. Anonymous functions break this:
// Leaks — you can't removeEventListener an anonymous function
window.addEventListener('resize', () => updateLayout())
// Correct
function handleResize() { updateLayout() }
window.addEventListener('resize', handleResize)
// ... later:
window.removeEventListener('resize', handleResize)AbortController for cleaner cleanup. The modern pattern uses AbortController to remove multiple listeners at once — useful in components or modules that set up several listeners and need to tear them all down:
const controller = new AbortController()
const { signal } = controller
window.addEventListener('resize', handleResize, { signal })
document.addEventListener('keydown', handleKeydown, { signal })
document.addEventListener('scroll', handleScroll, { signal })
// Tear down all three at once
controller.abort()This is the pattern React uses internally for effects cleanup, and it’s the cleanest way to manage listener lifecycle in any non-framework context.
Passive event listeners. For touchstart, touchmove, and wheel events, the browser blocks rendering to wait for your handler — because it doesn’t know if you’ll call e.preventDefault(). If you’re not preventing default scroll behavior, declare the listener passive. This eliminates the rendering block:
window.addEventListener('wheel', onScroll, { passive: true })
// Now the browser doesn't wait for your handler before scrollingExercise:
const input = document.querySelector<HTMLInputElement>('#search')!
const controller = new AbortController()
const { signal } = controller
input.addEventListener('input', (e: Event) => {
console.log('User typed:', (e.target as HTMLInputElement).value)
}, { signal })
input.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter') {
console.log('Submitted:', input.value)
controller.abort() // clean up both listeners after submission
}
}, { signal })4. Modifying Existing Elements
const title = document.querySelector<HTMLHeadingElement>('h1')!
title.textContent = 'New Title'
title.setAttribute('data-id', '42')
title.classList.add('highlighted')
title.classList.remove('hidden')
title.classList.toggle('active')CSS custom properties as the right way to modify styles. Setting el.style.color = 'red' inlines a style, which overrides the cascade and is hard to maintain. A better pattern for dynamic styling is to update CSS custom properties and let the stylesheet respond:
// Instead of: el.style.backgroundColor = theme.color
document.documentElement.style.setProperty('--accent-color', theme.color)
// Now every element using var(--accent-color) updates automaticallyThis is how design token systems work. The JS layer sets values; the CSS layer consumes them. The separation keeps visual logic in CSS where it belongs.
Layout thrashing. The performance pitfall no tutorial covers: interleaving DOM reads and writes forces the browser to flush its layout queue repeatedly. Each read after a write invalidates the cached layout.
// Bad — forces 3 layout recalculations
const h1 = el.offsetHeight // read — forces layout
el.style.height = h1 + 'px' // write — invalidates layout
const h2 = el.offsetHeight // read — forces layout again
el.style.marginTop = h2 + 'px' // write
const h3 = el.offsetHeight // read — forces layout again
// Good — batch reads, then writes
const h1 = el.offsetHeight // read
const h2 = el.clientHeight // read (layout already flushed once)
el.style.height = h1 + 'px' // write
el.style.marginTop = h2 + 'px' // write (layout flushed once at end)Properties that trigger layout (reflow): offsetHeight, offsetWidth, getBoundingClientRect(), scrollTop, clientWidth. Every time you read one of these after a DOM write, the browser synchronously recalculates the full layout tree. In a loop over 100 elements this is measurably slow.
5. Removing Elements
document.querySelector('.old-item')?.remove()Don’t forget the listeners. Removing an element from the DOM does not remove its event listeners. If listeners hold references to closures with significant memory, those closures stay alive. The correct pattern:
function destroyWidget(el: HTMLElement, controller: AbortController) {
controller.abort() // removes all listeners registered with this signal
el.remove()
}If you’re not using AbortController, you need to manually call removeEventListener before remove().
6. Observing the DOM — MutationObserver, IntersectionObserver, ResizeObserver
This is where the platform has evolved significantly beyond what most tutorials cover. Polling (setInterval to check if something changed) is almost never the right approach.
MutationObserver fires when the DOM tree changes. Use it to react to third-party DOM mutations, or to watch for elements that are added asynchronously:
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node instanceof HTMLElement && node.matches('.lazy-image')) {
loadImage(node)
}
})
}
}
})
observer.observe(document.body, { childList: true, subtree: true })
// Always disconnect when done
observer.disconnect()IntersectionObserver fires when an element enters or exits the viewport. This is the correct way to implement lazy loading, infinite scroll, and scroll-triggered animations — not scroll event listeners:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement
img.src = img.dataset.src!
observer.unobserve(img) // stop watching once loaded
}
})
}, { rootMargin: '200px' }) // start loading 200px before entering viewport
document.querySelectorAll<HTMLImageElement>('img[data-src]').forEach(img => {
observer.observe(img)
})The scroll event approach fires continuously during scrolling — potentially hundreds of times per second. IntersectionObserver fires only when the intersection state changes, and the callback runs off the main thread’s critical rendering path.
ResizeObserver fires when an element’s size changes. Use this instead of window.resize when you care about a specific element’s dimensions:
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect
if (width < 600) {
entry.target.classList.add('compact')
} else {
entry.target.classList.remove('compact')
}
}
})
observer.observe(document.querySelector<HTMLElement>('.sidebar')!)This is how component-level responsive behavior works without CSS container queries — though container queries (now baseline in all major browsers) are the CSS-native solution for this pattern.
7. Custom Events
The DOM event system supports custom events, which is the vanilla JS equivalent of a pub/sub system. Useful for decoupled communication between unrelated parts of a page:
// Dispatch a custom event with data
const event = new CustomEvent<{ userId: string }>('user:login', {
detail: { userId: '123' },
bubbles: true, // propagates up the DOM
cancelable: false,
})
document.dispatchEvent(event)
// Listen anywhere
document.addEventListener('user:login', (e: Event) => {
const { userId } = (e as CustomEvent<{ userId: string }>).detail
console.log('logged in:', userId)
})Namespacing event names (user:login, cart:update) avoids collisions with native events and makes event flows readable in large codebases.
8. Building a List Dynamically
const fruits = ['Apple', 'Banana', 'Cherry']
const list = document.querySelector<HTMLUListElement>('#fruit-list')!
const fragment = document.createDocumentFragment()
fruits.forEach(fruit => {
const li = document.createElement('li')
li.textContent = fruit
fragment.appendChild(li)
})
list.appendChild(fragment) // single reflowExercise: Build a clickable list using event delegation (not per-item listeners).
const names = ['Alice', 'Bob', 'Charlie']
const container = document.querySelector<HTMLDivElement>('#names')!
const fragment = document.createDocumentFragment()
names.forEach(name => {
const div = document.createElement('div')
div.textContent = name
div.dataset.name = name
fragment.appendChild(div)
})
container.appendChild(fragment)
// One listener via delegation — works even if items are added later
container.addEventListener('click', (e: MouseEvent) => {
const div = (e.target as Element).closest<HTMLDivElement>('div')
if (!div?.dataset.name) return
alert('You clicked: ' + div.dataset.name)
})9. Mini Todo — Putting It Together
const input = document.querySelector<HTMLInputElement>('#todo-input')!
const addBtn = document.querySelector<HTMLButtonElement>('#add-btn')!
const list = document.querySelector<HTMLUListElement>('#todo-list')!
// One controller to tear down all listeners if the widget is destroyed
const controller = new AbortController()
const { signal } = controller
addBtn.addEventListener('click', addTodo, { signal })
input.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter') addTodo()
}, { signal })
// Delegation handles delete for all current and future items
list.addEventListener('click', (e: MouseEvent) => {
const deleteBtn = (e.target as Element).closest<HTMLButtonElement>('.delete')
deleteBtn?.closest('li')?.remove()
}, { signal })
function addTodo() {
const text = input.value.trim()
if (!text) return
const li = document.createElement('li')
// Use textContent for user input — never innerHTML
li.textContent = text
const deleteBtn = document.createElement('button')
deleteBtn.textContent = 'X'
deleteBtn.className = 'delete'
deleteBtn.setAttribute('aria-label', `Delete "${text}"`)
li.appendChild(deleteBtn)
list.appendChild(li)
input.value = ''
}Note the aria-label on the delete button. A button with only text content “X” is meaningless to a screen reader. The label provides context: “Delete ‘Buy milk’”.
Why React Exists
This table is more useful once you’ve written the vanilla version:
| React | Vanilla JS | What React prevents |
|---|---|---|
onClick={fn} | addEventListener + manual cleanup | Listener leaks on unmount |
{text} in JSX | el.textContent = text | XSS from innerHTML misuse |
| State triggers re-render | You manually sync UI to data | Stale UI bugs when state diverges from DOM |
| Component unmount | controller.abort() | Forgetting cleanup entirely |
key prop in lists | Manual tracking of which element is which | O(n²) reconciliation, wrong element updates |
useRef | Direct DOM references | References going stale after re-renders |
The core problem vanilla JS can’t solve elegantly at scale is the synchronization problem: keeping the DOM in sync with application state. Every time you mutate state, you have to manually update every DOM node that depends on it. React’s reconciler automates this; its virtual DOM diff is the answer to the question of which DOM nodes actually need to change. Once you’ve written a non-trivial vanilla JS app and experienced the state-sync problem firsthand, React’s model stops feeling like magic and starts feeling like an obvious solution.