28 min read

Machine Coding Round Catalog

Vanilla JS — Logic First, Pattern Catalog

Format per pattern: Problem → State Model → HTML skeleton → JS implementation → Mistakes that cost rounds CSS is minimal and functional only. The focus is always the logic.


Layer 1: Core Mechanics

(What Every Round Assumes You Know Cold)


1. Debounce

What it is: delay executing a function until the user has stopped triggering it for N ms. If they trigger it again before N ms, reset the timer.

Mental model: every call cancels the previous scheduled call and schedules a new one.

javascript
function debounce(fn, delay) {
  let timer = null

  return function (...args) {
    clearTimeout(timer)                          // cancel the previous scheduled call
    timer = setTimeout(() => fn(...args), delay) // schedule a new one
  }
}

// Usage
const onSearch = debounce((query) => {
  console.log('searching:', query)
}, 300)

input.addEventListener('input', (e) => onSearch(e.target.value))

Mistakes:

  • Forgetting clearTimeout — without it, every call fires, which is just delayed execution, not debounce
  • Not spreading ...args — you lose the arguments
  • Calling debounce(fn, 300) inside the event listener — creates a new debounce instance on every event, so it never actually debounces

2. Throttle

What it is: allow a function to fire at most once per N ms. Unlike debounce, it fires immediately and blocks subsequent calls for N ms.

Mental model: first call fires. A flag blocks all calls for N ms. After N ms, flag resets. Next call fires again.

javascript
function throttle(fn, limit) {
  let blocked = false

  return function (...args) {
    if (blocked) return           // still in cooldown, ignore
    fn(...args)                   // fire immediately
    blocked = true
    setTimeout(() => {
      blocked = false             // cooldown over, ready to fire again
    }, limit)
  }
}

// Usage: fire at most once per 200ms during scroll
window.addEventListener('scroll', throttle(() => {
  console.log('scroll position:', window.scrollY)
}, 200))

Debounce vs Throttle — when to use which:

  • Search input → debounce (fire after user stops typing)
  • Scroll handler → throttle (fire periodically while scrolling)
  • Window resize → debounce (fire after resizing stops)
  • Button to prevent double-click → throttle

3. Event Delegation

What it is: instead of attaching one listener per element, attach one listener to the parent. Use event.target to determine which child was clicked.

Why: if you have 500 list items and attach addEventListener to each, you have 500 event listeners in memory. One listener on the parent handles all 500. Also works for dynamically added elements (new items added after the listener is attached still work).

html
<ul id="list">
  <li data-id="1">Item 1</li>
  <li data-id="2">Item 2</li>
  <li data-id="3">Item 3</li>
</ul>
javascript
const list = document.getElementById('list')

list.addEventListener('click', (e) => {
  // e.target is the actual element clicked
  // e.currentTarget is the element the listener is attached to (the ul)

  const item = e.target.closest('li') // handles clicks on children inside <li>
  if (!item) return                   // clicked on the ul itself, not an li

  const id = item.dataset.id
  console.log('clicked item:', id)
})

e.target.closest('li') — critical pattern. If the <li> contains child elements (like a <span> or <button>), e.target will be the child, not the <li>. .closest('li') walks up the DOM tree until it finds the matching ancestor. Use this always.

Mistakes:

  • Using e.target.tagName === 'LI' — breaks if the user clicks on a child element inside the <li>
  • Not null-checking after .closest() — if click is on the container itself, .closest() returns null

4. The Closure-in-Loop Trap

Classic interview trap. Understand it once, never get caught.

javascript
// WRONG — all buttons alert '3'
for (var i = 0; i < 3; i++) {
  document.querySelectorAll('button')[i].addEventListener('click', function() {
    alert(i) // 'i' is var — shared across all iterations, ends up as 3
  })
}

// FIX 1: use let (block scope — each iteration gets its own 'i')
for (let i = 0; i < 3; i++) {
  document.querySelectorAll('button')[i].addEventListener('click', function() {
    alert(i) // correct: 0, 1, 2
  })
}

// FIX 2: use a closure to capture the value
for (var i = 0; i < 3; i++) {
  (function(index) {
    document.querySelectorAll('button')[index].addEventListener('click', function() {
      alert(index)
    })
  })(i)
}

// FIX 3: store on the element itself (also common)
buttons.forEach((btn, i) => {
  btn.dataset.index = i
  btn.addEventListener('click', (e) => alert(e.target.dataset.index))
})

Root cause: var is function-scoped, not block-scoped. The event handler closes over the variable i, not the value of i at the time the loop ran. By the time any click fires, the loop has finished and i === 3. let is block-scoped — each iteration creates a new binding.


5. DOM API You Must Know Cold

javascript
// Selection
document.getElementById('id')
document.querySelector('.class')          // first match
document.querySelectorAll('.class')       // NodeList (not Array — use Array.from or [...])

// Creation and insertion
const el = document.createElement('div')
el.textContent = 'Hello'                  // safe (no XSS)
el.innerHTML = '<span>Hello</span>'       // use with caution
parent.appendChild(el)                    // add at end
parent.insertBefore(el, referenceNode)    // add before a node
parent.removeChild(el)                    // remove
el.remove()                               // shorthand remove

// Classes
el.classList.add('active')
el.classList.remove('active')
el.classList.toggle('active')
el.classList.contains('active')           // returns boolean

// Attributes
el.setAttribute('data-id', '123')
el.getAttribute('data-id')
el.dataset.id                             // shorthand for data-* attributes

// Traversal
el.parentElement
el.children                               // HTMLCollection of direct children
el.firstElementChild
el.lastElementChild
el.nextElementSibling
el.previousElementSibling
el.closest('.selector')                   // walks up tree, finds ancestor

// Dimensions (these force a layout calculation — batch reads, then writes)
el.offsetWidth / el.offsetHeight          // including padding and border
el.clientWidth / el.clientHeight          // including padding, excluding border
el.getBoundingClientRect()                // { top, left, bottom, right, width, height }
window.scrollY                            // current scroll position

// Events
el.addEventListener('click', handler)
el.removeEventListener('click', handler) // handler must be same reference
el.dispatchEvent(new CustomEvent('my-event', { detail: { data: 'value' } }))

Layer 2: UI Component Patterns


6. Accordion

Problem: build an accordion where clicking a header expands/collapses its content. Only one section open at a time.

State model:

  • activeIndex: which panel is currently open (null = none)
  • Click: if clicked panel is active → close it. If different panel → open it, close previous.
html
<div class="accordion" id="accordion">
  <div class="panel" data-index="0">
    <button class="header">Section 1</button>
    <div class="content">Content for section 1</div>
  </div>
  <div class="panel" data-index="1">
    <button class="header">Section 2</button>
    <div class="content">Content for section 2</div>
  </div>
  <div class="panel" data-index="2">
    <button class="header">Section 3</button>
    <div class="content">Content for section 3</div>
  </div>
</div>
javascript
const accordion = document.getElementById('accordion')
let activeIndex = null

accordion.addEventListener('click', (e) => {
  const header = e.target.closest('.header')
  if (!header) return

  const panel = header.closest('.panel')
  const clickedIndex = Number(panel.dataset.index)

  // Close currently open panel
  if (activeIndex !== null) {
    const openPanel = accordion.querySelector(`[data-index="${activeIndex}"]`)
    openPanel.querySelector('.content').hidden = true
    openPanel.querySelector('.header').setAttribute('aria-expanded', 'false')
  }

  // If same panel clicked, just close (toggle off)
  if (activeIndex === clickedIndex) {
    activeIndex = null
    return
  }

  // Open the clicked panel
  panel.querySelector('.content').hidden = false
  header.setAttribute('aria-expanded', 'true')
  activeIndex = clickedIndex
})

// Initialise — all closed
accordion.querySelectorAll('.content').forEach(c => c.hidden = true)
accordion.querySelectorAll('.header').forEach(h => h.setAttribute('aria-expanded', 'false'))

Mistakes:

  • Not handling the "click same panel again" case — you end up with no open panel and activeIndex still pointing to the old one
  • Using CSS display: none directly in JS instead of hidden attribute — hidden is semantically correct and simpler
  • Querying all panels on every click (inside the loop) — query once at init, or use delegation like above

7. Tabs

Problem: build a tab component. Click a tab, show its panel. Active tab is highlighted.

State model:

  • activeTab: which tab id is active
  • Click: deactivate all, activate clicked one
html
<div class="tabs">
  <div class="tab-list" role="tablist">
    <button class="tab" data-tab="tab1" aria-selected="true">Tab 1</button>
    <button class="tab" data-tab="tab2" aria-selected="false">Tab 2</button>
    <button class="tab" data-tab="tab3" aria-selected="false">Tab 3</button>
  </div>
  <div class="tab-panel active" id="tab1">Panel 1 content</div>
  <div class="tab-panel" id="tab2">Panel 2 content</div>
  <div class="tab-panel" id="tab3">Panel 3 content</div>
</div>
javascript
const tabList = document.querySelector('.tab-list')
const tabs = document.querySelectorAll('.tab')
const panels = document.querySelectorAll('.tab-panel')

// Initialise
panels.forEach(p => p.hidden = true)
document.getElementById('tab1').hidden = false

tabList.addEventListener('click', (e) => {
  const tab = e.target.closest('.tab')
  if (!tab) return

  const targetId = tab.dataset.tab

  // Deactivate all
  tabs.forEach(t => {
    t.classList.remove('active')
    t.setAttribute('aria-selected', 'false')
  })
  panels.forEach(p => p.hidden = true)

  // Activate clicked
  tab.classList.add('active')
  tab.setAttribute('aria-selected', 'true')
  document.getElementById(targetId).hidden = false
})

If they ask for keyboard support (arrow keys):

javascript
tabList.addEventListener('keydown', (e) => {
  const tabArray = [...tabs]
  const currentIndex = tabArray.indexOf(document.activeElement)

  if (e.key === 'ArrowRight') {
    const next = tabArray[(currentIndex + 1) % tabArray.length]
    next.focus()
    next.click()
  }
  if (e.key === 'ArrowLeft') {
    const prev = tabArray[(currentIndex - 1 + tabArray.length) % tabArray.length]
    prev.focus()
    prev.click()
  }
})

8. Modal

Problem: open a modal on button click. Close on X click, backdrop click, or Escape key.

State model:

  • isOpen: boolean
  • Three ways to close — handle all three
html
<button id="open-btn">Open Modal</button>

<div class="modal" id="modal" hidden>
  <div class="modal-backdrop"></div>
  <div class="modal-content">
    <button class="modal-close" id="close-btn">✕</button>
    <h2>Modal Title</h2>
    <p>Modal content here</p>
  </div>
</div>
javascript
const modal = document.getElementById('modal')
const openBtn = document.getElementById('open-btn')
const closeBtn = document.getElementById('close-btn')

function openModal() {
  modal.hidden = false
  document.body.style.overflow = 'hidden' // prevent background scroll
  closeBtn.focus()                         // accessibility: focus the close button
}

function closeModal() {
  modal.hidden = true
  document.body.style.overflow = ''
  openBtn.focus()                          // return focus to trigger element
}

openBtn.addEventListener('click', openModal)
closeBtn.addEventListener('click', closeModal)

// Close on backdrop click (not on modal content click)
modal.addEventListener('click', (e) => {
  if (e.target.classList.contains('modal-backdrop')) {
    closeModal()
  }
})

// Close on Escape
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && !modal.hidden) {
    closeModal()
  }
})

Mistakes:

  • Not preventing background scroll — page scrolls behind the modal
  • Closing modal when clicking the content inside it — the click event bubbles up to the backdrop check. The check e.target.classList.contains('modal-backdrop') prevents this — only the backdrop element itself triggers close
  • Not returning focus to the trigger on close — accessibility requirement, also asked by some interviewers

9. Dropdown Menu

Problem: click a button to toggle a dropdown. Close when clicking outside.

State model:

  • isOpen: boolean
  • Two close triggers: close button click, click anywhere outside
html
<div class="dropdown" id="dropdown">
  <button class="dropdown-trigger" id="trigger">Options ▾</button>
  <ul class="dropdown-menu" id="menu" hidden>
    <li><button data-value="edit">Edit</button></li>
    <li><button data-value="delete">Delete</button></li>
    <li><button data-value="share">Share</button></li>
  </ul>
</div>
javascript
const dropdown = document.getElementById('dropdown')
const trigger = document.getElementById('trigger')
const menu = document.getElementById('menu')

trigger.addEventListener('click', (e) => {
  e.stopPropagation()  // prevent the document click handler from firing immediately
  const isOpen = !menu.hidden
  menu.hidden = isOpen
  trigger.setAttribute('aria-expanded', String(!isOpen))
})

// Click outside closes the dropdown
document.addEventListener('click', (e) => {
  if (!dropdown.contains(e.target)) {
    menu.hidden = true
    trigger.setAttribute('aria-expanded', 'false')
  }
})

// Handle selection
menu.addEventListener('click', (e) => {
  const btn = e.target.closest('button')
  if (!btn) return
  console.log('selected:', btn.dataset.value)
  menu.hidden = true // close after selection
})

The stopPropagation reason: without it, clicking the trigger fires the trigger's click handler AND immediately bubbles to the document click handler, which closes the menu. The menu never opens. stopPropagation on the trigger prevents the document handler from firing on that specific click.

Mistakes:

  • Using window instead of document for outside click — small difference but document is more correct
  • Not using dropdown.contains(e.target) — checking only the trigger or menu separately misses cases

10. Typeahead / Autocomplete

Problem: as user types in an input, fetch suggestions and show a dropdown. Debounce the fetch. Keyboard navigate the suggestions.

State model:

  • query: current input value
  • suggestions: array of results from API
  • activeIndex: which suggestion is keyboard-highlighted (-1 = none)
  • isOpen: whether suggestions list is visible
html
<div class="autocomplete" id="autocomplete">
  <input type="text" id="search" placeholder="Search..." autocomplete="off">
  <ul id="suggestions" hidden></ul>
</div>
javascript
const input = document.getElementById('search')
const suggestionsList = document.getElementById('suggestions')

let suggestions = []
let activeIndex = -1

// Debounced fetch
const fetchSuggestions = debounce(async (query) => {
  if (!query.trim()) {
    hideSuggestions()
    return
  }

  const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
  suggestions = await res.json()
  renderSuggestions()
}, 300)

function renderSuggestions() {
  if (suggestions.length === 0) {
    hideSuggestions()
    return
  }

  suggestionsList.innerHTML = suggestions.map((item, i) => `
    <li data-index="${i}" class="${i === activeIndex ? 'active' : ''}">
      ${escapeHtml(item.label)}
    </li>
  `).join('')

  suggestionsList.hidden = false
}

function hideSuggestions() {
  suggestionsList.hidden = true
  activeIndex = -1
}

function escapeHtml(str) {
  // Prevent XSS — never put raw user/API data into innerHTML without escaping
  const div = document.createElement('div')
  div.textContent = str
  return div.innerHTML
}

// Input handler
input.addEventListener('input', (e) => {
  activeIndex = -1
  fetchSuggestions(e.target.value)
})

// Keyboard navigation
input.addEventListener('keydown', (e) => {
  if (suggestionsList.hidden) return

  if (e.key === 'ArrowDown') {
    e.preventDefault() // prevent cursor moving in input
    activeIndex = Math.min(activeIndex + 1, suggestions.length - 1)
    renderSuggestions()
  }

  if (e.key === 'ArrowUp') {
    e.preventDefault()
    activeIndex = Math.max(activeIndex - 1, -1)
    renderSuggestions()
  }

  if (e.key === 'Enter' && activeIndex >= 0) {
    selectSuggestion(suggestions[activeIndex])
  }

  if (e.key === 'Escape') {
    hideSuggestions()
  }
})

// Click selection
suggestionsList.addEventListener('click', (e) => {
  const li = e.target.closest('li')
  if (!li) return
  selectSuggestion(suggestions[Number(li.dataset.index)])
})

function selectSuggestion(item) {
  input.value = item.label
  hideSuggestions()
  console.log('selected:', item)
}

// Close on outside click
document.addEventListener('click', (e) => {
  if (!e.target.closest('#autocomplete')) {
    hideSuggestions()
  }
})

Mistakes:

  • Putting raw API data directly into innerHTML without escaping — XSS vulnerability. Always escape or use textContent.
  • Not debouncing — fires a fetch on every keystroke
  • Not handling the case where a newer request returns before an older one (race condition). Production fix: use an AbortController to cancel the previous request before making a new one.

Race condition fix:

javascript
let currentController = null

const fetchSuggestions = debounce(async (query) => {
  if (currentController) currentController.abort() // cancel previous
  currentController = new AbortController()

  try {
    const res = await fetch(`/api/search?q=${query}`, {
      signal: currentController.signal
    })
    suggestions = await res.json()
    renderSuggestions()
  } catch (e) {
    if (e.name === 'AbortError') return // expected, ignore
  }
}, 300)

11. Infinite Scroll

Problem: load more items as the user scrolls to the bottom of the list.

State model:

  • page: current page number
  • isLoading: prevent multiple simultaneous fetches
  • hasMore: stop fetching when no more data

Two approaches: scroll event (simpler) vs IntersectionObserver (better).

Approach 1: Scroll event

javascript
let page = 1
let isLoading = false
let hasMore = true
const list = document.getElementById('list')

async function loadMore() {
  if (isLoading || !hasMore) return

  isLoading = true
  showLoader()

  const data = await fetch(`/api/items?page=${page}`).then(r => r.json())

  if (data.items.length === 0) {
    hasMore = false
    hideLoader()
    return
  }

  data.items.forEach(item => {
    const el = document.createElement('div')
    el.textContent = item.name
    list.appendChild(el)
  })

  page++
  isLoading = false
  hideLoader()
}

// Throttle the scroll handler
window.addEventListener('scroll', throttle(() => {
  const { scrollTop, scrollHeight, clientHeight } = document.documentElement
  const nearBottom = scrollTop + clientHeight >= scrollHeight - 200 // 200px before bottom

  if (nearBottom) loadMore()
}, 200))

loadMore() // initial load

Approach 2: IntersectionObserver (cleaner)

javascript
const sentinel = document.getElementById('sentinel') // empty div at bottom of list

const observer = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) {
    loadMore()
  }
}, {
  rootMargin: '200px' // trigger 200px before it actually enters viewport
})

observer.observe(sentinel)

// When no more data
if (!hasMore) observer.disconnect()

IntersectionObserver is the right answer for any interviewer who asks about performance. It fires only when the sentinel element enters the viewport — no scroll event polling, no layout thrashing from reading scrollTop/scrollHeight on every scroll.


12. Star Rating

Problem: render N stars. User can hover to preview a rating and click to set it. Show the set rating when not hovering.

State model:

  • currentRating: the committed rating (0-5)
  • hoverRating: the preview rating while mouse is over stars (0 = not hovering)
  • Display rule: if hoverRating > 0, show hoverRating. Otherwise show currentRating.
html
<div class="stars" id="stars" data-rating="0">
  <span data-value="1">★</span>
  <span data-value="2">★</span>
  <span data-value="3">★</span>
  <span data-value="4">★</span>
  <span data-value="5">★</span>
</div>
javascript
const stars = document.getElementById('stars')
const starEls = stars.querySelectorAll('[data-value]')
let currentRating = 0

function render(displayRating) {
  starEls.forEach(star => {
    const val = Number(star.dataset.value)
    star.classList.toggle('filled', val <= displayRating)
  })
}

stars.addEventListener('mouseover', (e) => {
  const star = e.target.closest('[data-value]')
  if (!star) return
  render(Number(star.dataset.value)) // preview
})

stars.addEventListener('mouseleave', () => {
  render(currentRating) // reset to committed rating
})

stars.addEventListener('click', (e) => {
  const star = e.target.closest('[data-value]')
  if (!star) return
  const val = Number(star.dataset.value)

  // Clicking the same rating again resets to 0 (toggle off)
  currentRating = currentRating === val ? 0 : val
  stars.dataset.rating = currentRating
  render(currentRating)
})

render(0) // init

Mental model: there are always two values — what the user has set (currentRating) and what they're hovering on (hoverRating). The display always shows hover if it exists, otherwise shows the committed rating. On mouseleave, reset to committed.


13. Toast Notifications

Problem: show a toast notification. Auto-dismiss after N seconds. Support multiple toasts stacked. Support manual dismiss.

State model:

  • A container holds all active toasts
  • Each toast has: id, message, type, timer
  • Dismiss = remove DOM element + clear timer
html
<div id="toast-container"></div>
<button onclick="showToast('Saved!', 'success')">Save</button>
javascript
const container = document.getElementById('toast-container')

function showToast(message, type = 'info', duration = 3000) {
  const id = Date.now() // simple unique id
  const toast = document.createElement('div')
  toast.className = `toast toast-${type}`
  toast.dataset.id = id
  toast.innerHTML = `
    <span>${message}</span>
    <button class="toast-close" onclick="dismissToast(${id})">✕</button>
  `

  container.appendChild(toast)

  // Auto-dismiss
  const timer = setTimeout(() => dismissToast(id), duration)
  toast.dataset.timer = timer // store timer id on element for cleanup

  // Animate in
  requestAnimationFrame(() => toast.classList.add('visible'))

  return id
}

function dismissToast(id) {
  const toast = container.querySelector(`[data-id="${id}"]`)
  if (!toast) return

  clearTimeout(Number(toast.dataset.timer))
  toast.classList.remove('visible')

  // Remove from DOM after transition
  toast.addEventListener('transitionend', () => toast.remove(), { once: true })
}

The { once: true } option on addEventListener — the listener automatically removes itself after firing once. Without this, every transitionend would fire the listener again if more transitions happen on the element.

Mistakes:

  • Not clearing the timer when manually dismissed — if the user dismisses a toast and then the timer fires, it tries to find and remove an already-removed element (harmless but sloppy)
  • Mutating innerHTML of the container on each toast — always append/remove individual elements, never replace the entire container

14. Form Validation

Problem: validate a form on submit. Show field-level errors. Prevent submission if invalid.

State model:

  • Validate each field against rules
  • errors: map of fieldName → errorMessage
  • Show errors, focus first invalid field, block submit
html
<form id="form">
  <div class="field">
    <input type="text" name="name" placeholder="Full Name" />
    <span class="error" data-field="name"></span>
  </div>
  <div class="field">
    <input type="email" name="email" placeholder="Email" />
    <span class="error" data-field="email"></span>
  </div>
  <div class="field">
    <input type="password" name="password" placeholder="Password" />
    <span class="error" data-field="password"></span>
  </div>
  <button type="submit">Submit</button>
</form>
javascript
const form = document.getElementById('form')

// Validation rules — easily extensible
const rules = {
  name: [
    { test: v => v.trim().length > 0, message: 'Name is required' },
    { test: v => v.trim().length >= 2, message: 'Name must be at least 2 characters' },
  ],
  email: [
    { test: v => v.trim().length > 0, message: 'Email is required' },
    { test: v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), message: 'Invalid email format' },
  ],
  password: [
    { test: v => v.length > 0, message: 'Password is required' },
    { test: v => v.length >= 8, message: 'Password must be at least 8 characters' },
  ],
}

function validateField(name, value) {
  const fieldRules = rules[name]
  if (!fieldRules) return null

  for (const rule of fieldRules) {
    if (!rule.test(value)) return rule.message
  }
  return null // no error
}

function showError(fieldName, message) {
  const errorEl = form.querySelector(`[data-field="${fieldName}"]`)
  const input = form.querySelector(`[name="${fieldName}"]`)
  if (errorEl) errorEl.textContent = message || ''
  if (input) input.classList.toggle('invalid', !!message)
}

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

  const data = Object.fromEntries(new FormData(form))
  let firstError = null

  // Validate all fields
  for (const [name, value] of Object.entries(data)) {
    const error = validateField(name, value)
    showError(name, error)

    if (error && !firstError) {
      firstError = name
    }
  }

  if (firstError) {
    form.querySelector(`[name="${firstError}"]`).focus()
    return
  }

  // All valid — submit
  console.log('submitting:', data)
})

// Inline validation on blur (validate as user leaves a field)
form.addEventListener('blur', (e) => {
  const input = e.target
  if (!input.name || !rules[input.name]) return
  const error = validateField(input.name, input.value)
  showError(input.name, error)
}, true) // capture phase to catch blur (it doesn't bubble)

new FormData(form) — native browser API that collects all named form inputs into key-value pairs. Object.fromEntries() converts it to a plain object. Cleaner than querying each input manually.

blur with capture phase — blur does not bubble. To catch it via event delegation on the form, you must use the capture phase (true as third argument to addEventListener).


15. Drag and Drop (From Scratch)

Problem: make list items reorderable by dragging.

State model:

  • draggedEl: the element being dragged
  • Three events: dragstart, dragover, drop
html
<ul id="drag-list">
  <li draggable="true" data-id="1">Item 1</li>
  <li draggable="true" data-id="2">Item 2</li>
  <li draggable="true" data-id="3">Item 3</li>
</ul>
javascript
const list = document.getElementById('drag-list')
let draggedEl = null

list.addEventListener('dragstart', (e) => {
  draggedEl = e.target
  e.target.classList.add('dragging')
  e.dataTransfer.effectAllowed = 'move'
})

list.addEventListener('dragend', (e) => {
  e.target.classList.remove('dragging')
  draggedEl = null
  // Remove any visual indicators
  list.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'))
})

list.addEventListener('dragover', (e) => {
  e.preventDefault() // REQUIRED — without this, drop event won't fire
  const target = e.target.closest('li')
  if (!target || target === draggedEl) return

  // Determine whether to insert before or after
  const rect = target.getBoundingClientRect()
  const midY = rect.top + rect.height / 2

  if (e.clientY < midY) {
    list.insertBefore(draggedEl, target)
  } else {
    list.insertBefore(draggedEl, target.nextSibling)
  }
})

list.addEventListener('drop', (e) => {
  e.preventDefault()
  // The actual reordering happens in dragover, drop just finalises
})

e.preventDefault() in dragover is mandatory. Without it, the browser treats the drop target as invalid and the drop event never fires. This is the #1 drag-and-drop mistake.

Insert above/below logic — getBoundingClientRect() gets the target element's position. If the mouse is above the midpoint of the target, insert before. If below, insert after. This gives natural feel.


16. Pagination

Problem: display paginated data. Render page number buttons. Handle prev/next and direct page clicks.

State model:

  • currentPage: 1-indexed
  • totalPages: derived from total / pageSize
  • Click: update currentPage, fetch new data, re-render
html
<div id="items-container"></div>
<div id="pagination"></div>
javascript
const PAGE_SIZE = 10
let currentPage = 1
let totalItems = 0

async function loadPage(page) {
  const res = await fetch(`/api/items?page=${page}&size=${PAGE_SIZE}`)
  const { items, total } = await res.json()
  totalItems = total

  renderItems(items)
  renderPagination(Math.ceil(total / PAGE_SIZE))
  currentPage = page

  window.scrollTo({ top: 0, behavior: 'smooth' })
}

function renderItems(items) {
  const container = document.getElementById('items-container')
  container.innerHTML = items.map(item => `
    <div class="item">${escapeHtml(item.name)}</div>
  `).join('')
}

function renderPagination(totalPages) {
  const el = document.getElementById('pagination')

  // Build page numbers — show max 5 around current page
  const pages = getPageRange(currentPage, totalPages)

  el.innerHTML = `
    <button data-page="${currentPage - 1}" ${currentPage === 1 ? 'disabled' : ''}>‹ Prev</button>
    ${pages.map(p =>
      p === '...'
        ? '<span>...</span>'
        : `<button data-page="${p}" class="${p === currentPage ? 'active' : ''}">${p}</button>`
    ).join('')}
    <button data-page="${currentPage + 1}" ${currentPage === totalPages ? 'disabled' : ''}>Next ›</button>
  `
}

function getPageRange(current, total) {
  // Always show: first, last, current, and 1 on each side of current
  const pages = new Set([1, total, current, current - 1, current + 1])
  const sorted = [...pages].filter(p => p >= 1 && p <= total).sort((a, b) => a - b)

  // Insert '...' where gaps exist
  const result = []
  for (let i = 0; i < sorted.length; i++) {
    if (i > 0 && sorted[i] - sorted[i - 1] > 1) result.push('...')
    result.push(sorted[i])
  }
  return result
}

document.getElementById('pagination').addEventListener('click', (e) => {
  const btn = e.target.closest('button')
  if (!btn || btn.disabled) return
  loadPage(Number(btn.dataset.page))
})

loadPage(1)

17. Search with Live Filter (No API)

Problem: filter a list as the user types. No API — filter from an in-memory array.

State model:

  • allItems: the full list (never mutated)
  • query: current search string
  • On each input: filter allItems and re-render
javascript
const allItems = [
  { id: 1, name: 'Apple' },
  { id: 2, name: 'Banana' },
  { id: 3, name: 'Cherry' },
  // ...
]

const input = document.getElementById('search')
const list = document.getElementById('list')

function render(items) {
  if (items.length === 0) {
    list.innerHTML = '<li>No results</li>'
    return
  }
  list.innerHTML = items.map(item => `<li>${escapeHtml(item.name)}</li>`).join('')
}

input.addEventListener('input', debounce((e) => {
  const query = e.target.value.toLowerCase().trim()

  const filtered = query
    ? allItems.filter(item => item.name.toLowerCase().includes(query))
    : allItems

  render(filtered)
}, 200))

render(allItems) // initial render

Never mutate the original array. allItems is the source of truth. Always filter from it, never from a filtered copy. This is the mistake that causes "I can only filter once and can't go back."


18. Multi-Step Form / Wizard

Problem: a form split into multiple steps. Next/Back navigation. Validate each step before allowing next.

State model:

  • currentStep: 0-indexed
  • formData: accumulated data across all steps
  • Validate current step on Next click
html
<div id="wizard">
  <div class="step" data-step="0">
    <h2>Step 1: Personal Info</h2>
    <input name="name" placeholder="Name" />
    <input name="email" placeholder="Email" />
  </div>
  <div class="step" data-step="1" hidden>
    <h2>Step 2: Account</h2>
    <input name="username" placeholder="Username" />
    <input name="password" type="password" placeholder="Password" />
  </div>
  <div class="step" data-step="2" hidden>
    <h2>Step 3: Review</h2>
    <div id="summary"></div>
  </div>

  <div class="controls">
    <button id="back-btn" hidden>Back</button>
    <button id="next-btn">Next</button>
  </div>

  <div id="progress">Step 1 of 3</div>
</div>
javascript
const steps = document.querySelectorAll('.step')
const nextBtn = document.getElementById('next-btn')
const backBtn = document.getElementById('back-btn')
const progress = document.getElementById('progress')

let currentStep = 0
let formData = {}

const validations = {
  0: () => {
    const name = document.querySelector('[name="name"]').value.trim()
    const email = document.querySelector('[name="email"]').value.trim()
    if (!name) return 'Name is required'
    if (!email || !email.includes('@')) return 'Valid email is required'
    return null
  },
  1: () => {
    const username = document.querySelector('[name="username"]').value.trim()
    const password = document.querySelector('[name="password"]').value
    if (!username || username.length < 3) return 'Username must be at least 3 characters'
    if (!password || password.length < 8) return 'Password must be at least 8 characters'
    return null
  },
}

function goToStep(step) {
  steps[currentStep].hidden = true
  currentStep = step
  steps[currentStep].hidden = false

  backBtn.hidden = currentStep === 0
  nextBtn.textContent = currentStep === steps.length - 1 ? 'Submit' : 'Next'
  progress.textContent = `Step ${currentStep + 1} of ${steps.length}`

  if (currentStep === steps.length - 1) {
    renderSummary()
  }
}

function collectCurrentStepData() {
  const currentStepEl = steps[currentStep]
  const inputs = currentStepEl.querySelectorAll('input')
  inputs.forEach(input => {
    formData[input.name] = input.value
  })
}

function renderSummary() {
  document.getElementById('summary').innerHTML = Object.entries(formData)
    .map(([key, val]) => `<p><strong>${key}:</strong> ${escapeHtml(val)}</p>`)
    .join('')
}

nextBtn.addEventListener('click', () => {
  const validate = validations[currentStep]
  if (validate) {
    const error = validate()
    if (error) {
      alert(error) // in a real app, show inline error
      return
    }
  }

  collectCurrentStepData()

  if (currentStep === steps.length - 1) {
    console.log('submitting:', formData)
    return
  }

  goToStep(currentStep + 1)
})

backBtn.addEventListener('click', () => {
  goToStep(currentStep - 1)
})

19. Character Counter

Problem: show remaining characters as user types. Warn when near/at limit. Disable input at limit.

html
<div class="input-wrapper">
  <textarea id="bio" maxlength="280" placeholder="Write your bio..."></textarea>
  <span id="counter">280 remaining</span>
</div>
javascript
const textarea = document.getElementById('bio')
const counter = document.getElementById('counter')
const MAX = 280

textarea.addEventListener('input', () => {
  const remaining = MAX - textarea.value.length

  counter.textContent = `${remaining} remaining`
  counter.className = remaining <= 20
    ? (remaining <= 0 ? 'counter-limit' : 'counter-warn')
    : ''
})

Simple but interviewers use it to check: do you know maxlength? Do you update on input vs keydown? (keydown fires before the character is added, input fires after — always use input for value-dependent UI).


20. OTP Input

Problem: 6 boxes, one digit each. Auto-advance to next box on input. Backspace goes back. Paste fills all boxes.

html
<div class="otp" id="otp">
  <input type="text" maxlength="1" inputmode="numeric" />
  <input type="text" maxlength="1" inputmode="numeric" />
  <input type="text" maxlength="1" inputmode="numeric" />
  <input type="text" maxlength="1" inputmode="numeric" />
  <input type="text" maxlength="1" inputmode="numeric" />
  <input type="text" maxlength="1" inputmode="numeric" />
</div>
javascript
const otp = document.getElementById('otp')
const inputs = [...otp.querySelectorAll('input')]

inputs.forEach((input, index) => {
  // Only allow digits
  input.addEventListener('keydown', (e) => {
    if (!/^\d$/.test(e.key) && !['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
      e.preventDefault()
    }
  })

  input.addEventListener('input', () => {
    if (input.value && index < inputs.length - 1) {
      inputs[index + 1].focus() // auto-advance
    }
    checkComplete()
  })

  input.addEventListener('keydown', (e) => {
    if (e.key === 'Backspace' && !input.value && index > 0) {
      inputs[index - 1].focus() // go back on backspace when empty
    }
  })

  // Handle paste — fill all boxes from pasted text
  input.addEventListener('paste', (e) => {
    e.preventDefault()
    const paste = e.clipboardData.getData('text').replace(/\D/g, '') // digits only
    paste.split('').slice(0, inputs.length).forEach((char, i) => {
      inputs[i].value = char
    })
    // Focus last filled input
    const lastFilled = Math.min(paste.length, inputs.length) - 1
    inputs[lastFilled].focus()
    checkComplete()
  })
})

function checkComplete() {
  const value = inputs.map(i => i.value).join('')
  if (value.length === inputs.length) {
    console.log('OTP complete:', value)
  }
}

inputs[0].focus() // auto-focus first input

Layer 3: The Harder Patterns


21. Custom Event Emitter (pub/sub)

Problem: implement an event emitter class with on, off, emit, and once.

This is asked in almost every senior round.

javascript
class EventEmitter {
  constructor() {
    this.events = {} // { eventName: [handlers] }
  }

  on(event, handler) {
    if (!this.events[event]) {
      this.events[event] = []
    }
    this.events[event].push(handler)
    return this // allow chaining: emitter.on('a', fn).on('b', fn2)
  }

  off(event, handler) {
    if (!this.events[event]) return this
    this.events[event] = this.events[event].filter(h => h !== handler)
    return this
  }

  emit(event, ...args) {
    if (!this.events[event]) return this
    // Spread to copy — so removing during emit doesn't affect current iteration
    [...this.events[event]].forEach(handler => handler(...args))
    return this
  }

  once(event, handler) {
    // Wrapper that removes itself after first call
    const wrapper = (...args) => {
      handler(...args)
      this.off(event, wrapper)
    }
    // Store reference to wrapper on the original handler
    // so off(event, originalHandler) works
    wrapper._original = handler
    return this.on(event, wrapper)
  }

  // off with original handler reference support
  off(event, handler) {
    if (!this.events[event]) return this
    this.events[event] = this.events[event].filter(h =>
      h !== handler && h._original !== handler
    )
    return this
  }
}

// Usage
const emitter = new EventEmitter()

emitter.on('data', (payload) => console.log('received:', payload))
emitter.emit('data', { id: 1 })  // received: { id: 1 }

emitter.once('connect', () => console.log('connected')) // fires once, then removes itself
emitter.emit('connect') // 'connected'
emitter.emit('connect') // nothing — already removed

The once subtlety: off(event, originalHandler) must work even though the actual registered handler is a wrapper. The _original property stores the reference so the filter can match by original handler.


22. Promise Implementation (from scratch)

Asked to test if you actually understand async — not just how to use it.

javascript
class MyPromise {
  constructor(executor) {
    this.state = 'pending'    // 'pending' | 'fulfilled' | 'rejected'
    this.value = undefined
    this.handlers = []        // { onFulfilled, onRejected, resolve, reject } for chaining

    const resolve = (value) => {
      if (this.state !== 'pending') return
      this.state = 'fulfilled'
      this.value = value
      this.handlers.forEach(h => this._handleResolved(h))
    }

    const reject = (reason) => {
      if (this.state !== 'pending') return
      this.state = 'rejected'
      this.value = reason
      this.handlers.forEach(h => this._handleResolved(h))
    }

    try {
      executor(resolve, reject)
    } catch (e) {
      reject(e)
    }
  }

  _handleResolved({ onFulfilled, onRejected, resolve, reject }) {
    // Run asynchronously (microtask) — real Promises resolve async
    queueMicrotask(() => {
      try {
        if (this.state === 'fulfilled') {
          if (typeof onFulfilled === 'function') {
            resolve(onFulfilled(this.value))
          } else {
            resolve(this.value) // pass through
          }
        } else if (this.state === 'rejected') {
          if (typeof onRejected === 'function') {
            resolve(onRejected(this.value)) // caught = resolved
          } else {
            reject(this.value) // unhandled = propagate
          }
        }
      } catch (e) {
        reject(e)
      }
    })
  }

  then(onFulfilled, onRejected) {
    return new MyPromise((resolve, reject) => {
      const handler = { onFulfilled, onRejected, resolve, reject }

      if (this.state === 'pending') {
        this.handlers.push(handler)
      } else {
        this._handleResolved(handler) // already settled — run immediately
      }
    })
  }

  catch(onRejected) {
    return this.then(null, onRejected)
  }

  static resolve(value) {
    return new MyPromise(resolve => resolve(value))
  }

  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason))
  }

  static all(promises) {
    return new MyPromise((resolve, reject) => {
      const results = []
      let resolved = 0

      if (promises.length === 0) { resolve([]); return }

      promises.forEach((p, i) => {
        MyPromise.resolve(p).then(value => {
          results[i] = value
          resolved++
          if (resolved === promises.length) resolve(results)
        }).catch(reject)
      })
    })
  }

  static race(promises) {
    return new MyPromise((resolve, reject) => {
      promises.forEach(p => {
        MyPromise.resolve(p).then(resolve).catch(reject)
      })
    })
  }
}

What interviewers look for:

  • State machine: pending → fulfilled/rejected (one-way, no reverting)
  • .then() returns a new Promise (enabling chaining)
  • Async resolution via queueMicrotask (not setTimeout)
  • Error propagation through the chain

23. Virtual List (Windowing from Scratch)

Problem: render a list of 100,000 items without crashing the browser.

Core idea: only render items visible in the viewport. Use a tall container (representing the full list height) and absolutely position only the visible rows.

html
<div id="virtual-list"></div>
javascript
const ITEM_HEIGHT = 50       // fixed height per item (required for simple virtualisation)
const CONTAINER_HEIGHT = 600 // the viewport height
const BUFFER = 5             // extra rows to render above/below viewport

const items = Array.from({ length: 100000 }, (_, i) => ({ id: i, name: `Item ${i}` }))

const container = document.getElementById('virtual-list')
container.style.height = `${CONTAINER_HEIGHT}px`
container.style.overflow = 'auto'
container.style.position = 'relative'

// Inner div — full height so the scrollbar is correct
const inner = document.createElement('div')
inner.style.height = `${items.length * ITEM_HEIGHT}px`
inner.style.position = 'relative'
container.appendChild(inner)

function getVisibleRange(scrollTop) {
  const startIndex = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER)
  const endIndex = Math.min(
    items.length - 1,
    Math.ceil((scrollTop + CONTAINER_HEIGHT) / ITEM_HEIGHT) + BUFFER
  )
  return { startIndex, endIndex }
}

function render(scrollTop) {
  const { startIndex, endIndex } = getVisibleRange(scrollTop)

  // Clear only what's rendered (not the entire inner)
  inner.innerHTML = ''

  for (let i = startIndex; i <= endIndex; i++) {
    const el = document.createElement('div')
    el.style.position = 'absolute'
    el.style.top = `${i * ITEM_HEIGHT}px`
    el.style.height = `${ITEM_HEIGHT}px`
    el.style.width = '100%'
    el.textContent = items[i].name
    inner.appendChild(el)
  }
}

container.addEventListener('scroll', throttle(() => {
  render(container.scrollTop)
}, 16)) // ~60fps

render(0) // initial render

What you're doing: the container has a fixed visible height with overflow: auto. The inner div is 100,000 × 50px = 5,000,000px tall — this creates the correct scrollbar. You only render ~12-15 items at any time (viewport / item height + buffer). As the user scrolls, you recompute which items are visible and swap them in.


24. Promise.all, Promise.allSettled, Promise.race — Implement Them

javascript
// Promise.all — resolves when ALL resolve, rejects if ANY reject
function promiseAll(promises) {
  return new Promise((resolve, reject) => {
    const results = new Array(promises.length)
    let remaining = promises.length

    if (remaining === 0) { resolve([]); return }

    promises.forEach((p, i) => {
      Promise.resolve(p)
        .then(val => {
          results[i] = val  // preserve order
          remaining--
          if (remaining === 0) resolve(results)
        })
        .catch(reject)       // first rejection rejects the whole thing
    })
  })
}

// Promise.allSettled — waits for ALL, never rejects, returns status objects
function promiseAllSettled(promises) {
  return new Promise((resolve) => {
    const results = new Array(promises.length)
    let remaining = promises.length

    if (remaining === 0) { resolve([]); return }

    promises.forEach((p, i) => {
      Promise.resolve(p)
        .then(value => {
          results[i] = { status: 'fulfilled', value }
        })
        .catch(reason => {
          results[i] = { status: 'rejected', reason }
        })
        .finally(() => {
          remaining--
          if (remaining === 0) resolve(results)
        })
    })
  })
}

// Promise.race — resolves/rejects with the first settled promise
function promiseRace(promises) {
  return new Promise((resolve, reject) => {
    promises.forEach(p => {
      Promise.resolve(p).then(resolve).catch(reject)
    })
  })
}

// Promise.any — resolves with first fulfilled, rejects if ALL reject
function promiseAny(promises) {
  return new Promise((resolve, reject) => {
    let rejectedCount = 0
    const errors = new Array(promises.length)

    promises.forEach((p, i) => {
      Promise.resolve(p)
        .then(resolve)  // first success wins
        .catch(err => {
          errors[i] = err
          rejectedCount++
          if (rejectedCount === promises.length) {
            reject(new AggregateError(errors, 'All promises rejected'))
          }
        })
    })
  })
}

25. Intersection Observer — Lazy Load and Scroll-Triggered Animations

javascript
// Lazy load images
const images = document.querySelectorAll('img[data-src]')

const imageObserver = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    if (!entry.isIntersecting) return

    const img = entry.target
    img.src = img.dataset.src        // swap data-src to src — triggers load
    img.removeAttribute('data-src')
    observer.unobserve(img)          // stop observing once loaded
  })
}, {
  rootMargin: '200px',  // load 200px before they enter viewport
})

images.forEach(img => imageObserver.observe(img))
javascript
// Scroll-triggered animation (reveal on scroll)
const animatedEls = document.querySelectorAll('.animate-on-scroll')

const animationObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('visible')
    }
  })
}, {
  threshold: 0.1  // trigger when 10% of element is visible
})

animatedEls.forEach(el => animationObserver.observe(el))

threshold: a number from 0 to 1. 0 = trigger when even 1px is visible. 1 = trigger when fully visible. 0.5 = trigger when 50% is visible.

rootMargin: like margin on the intersection root. Positive values expand the trigger zone (load before entering viewport). Negative values shrink it.


Quick Reference: Patterns by Interview Frequency

Almost always asked (know these cold):

  • Debounce / Throttle
  • Event Delegation
  • Accordion / Tabs / Modal
  • Infinite Scroll
  • Form Validation

Commonly asked:

  • Autocomplete / Typeahead
  • Dropdown
  • Drag and Drop
  • Pagination
  • Toast Notifications
  • Star Rating

Asked at senior level:

  • Custom Event Emitter
  • Promise implementation
  • Virtual List
  • Promise.all / race / allSettled implementation
  • IntersectionObserver patterns

The 5 Things That Lose Rounds

1. Not having a state model before writing code. Before typing HTML, say out loud or comment: "My state is X. Events that change it are Y. The render function takes state and produces DOM." Engineers who jump to code first write themselves into corners.

2. Mutating the original data. Filter from a copy. Render from the original. Never splice, sort, or reassign a variable that holds the source of truth.

3. Not handling edge cases:

  • Empty state (no results, no items)
  • Loading state (async hasn't resolved)
  • Error state (fetch failed) Interviewers specifically wait to see if you handle these.

4. Memory leaks — event listeners not cleaned up. In a multi-component vanilla app, removing a DOM element does not automatically remove its event listeners. If you add them, remove them. Especially timers (clearTimeout, clearInterval).

5. innerHTML with unsanitised data. If the data comes from a user or an API, always use textContent or escape before putting in innerHTML. Interviewers who care about security will mark you down hard for this.