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 here

Live 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 count

Prefer 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 reflow

For 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 parsing

Never 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 bubble

Event 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 scrolling

Exercise:

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 automatically

This 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 reflow

Exercise: 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:

ReactVanilla JSWhat React prevents
onClick={fn}addEventListener + manual cleanupListener leaks on unmount
{text} in JSXel.textContent = textXSS from innerHTML misuse
State triggers re-renderYou manually sync UI to dataStale UI bugs when state diverges from DOM
Component unmountcontroller.abort()Forgetting cleanup entirely
key prop in listsManual tracking of which element is whichO(n²) reconciliation, wrong element updates
useRefDirect DOM referencesReferences 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.