System Designhard

Design an autocomplete / type-ahead component

Design and implement an autocomplete input that fetches suggestions from an API as the user types. Handle debouncing, race conditions, keyboard navigation, and accessibility.

Asked at Airbnb, Amazon, Google, Tenable

#debounce#async#race conditions#a11y#UX

Answer

class Autocomplete {
  constructor(inputEl, fetchFn, options = {}) {
    this.input = inputEl
    this.fetch = fetchFn
    this.delay = options.delay ?? 300
    this.minChars = options.minChars ?? 2

    this.listEl = this._createList()
    this.timer = null
    this.activeRequest = null
    this.activeIndex = -1

    this.input.addEventListener('input',   e => this._onInput(e))
    this.input.addEventListener('keydown', e => this._onKeydown(e))
    this.input.setAttribute('autocomplete', 'off')
    this.input.setAttribute('aria-autocomplete', 'list')
    this.input.setAttribute('role', 'combobox')
  }

  _onInput({ target: { value } }) {
    clearTimeout(this.timer)
    if (value.length < this.minChars) return this._hide()

    this.timer = setTimeout(async () => {
      // Cancel any pending request (race condition fix)
      const requestId = Symbol()
      this.activeRequest = requestId

      const results = await this.fetch(value)

      if (this.activeRequest !== requestId) return  // stale

      this._render(results)
    }, this.delay)
  }

  _onKeydown(e) {
    const items = this.listEl.querySelectorAll('[role="option"]')
    if (e.key === 'ArrowDown') {
      e.preventDefault()
      this.activeIndex = Math.min(this.activeIndex + 1, items.length - 1)
    } else if (e.key === 'ArrowUp') {
      e.preventDefault()
      this.activeIndex = Math.max(this.activeIndex - 1, -1)
    } else if (e.key === 'Enter' && this.activeIndex >= 0) {
      items[this.activeIndex]?.click()
    } else if (e.key === 'Escape') {
      this._hide()
    }
    items.forEach((item, i) =>
      item.setAttribute('aria-selected', String(i === this.activeIndex))
    )
  }

  _render(items) {
    this.listEl.innerHTML = ''
    this.activeIndex = -1
    items.forEach(text => {
      const li = document.createElement('li')
      li.textContent = text
      li.setAttribute('role', 'option')
      li.addEventListener('click', () => {
        this.input.value = text
        this._hide()
      })
      this.listEl.appendChild(li)
    })
    this.listEl.hidden = items.length === 0
  }

  _hide() { this.listEl.hidden = true }

  _createList() {
    const ul = document.createElement('ul')
    ul.setAttribute('role', 'listbox')
    ul.hidden = true
    this.input.parentNode.appendChild(ul)
    return ul
  }
}

// Usage
const inputEl = document.querySelector('#search')
const fetchFn = async (q) => {
  const r = await fetch('/api/suggest?q=' + encodeURIComponent(q))
  return r.json()
}
new Autocomplete(inputEl, fetchFn, { delay: 250, minChars: 2 })

Source: frontendinterviewhandbook.com — Airbnb

Practise more System Design questions →