Fossils🛠ïļ Machine CodingBuild an Autocomplete / Typeahead Search
ðŸĶ–DinosaurMachine CodingSearchDebounceAsync

Build an Autocomplete / Typeahead Search

Autocomplete combines debouncing, async data fetching, keyboard navigation, caching, and accessibility. It's one of the most comprehensive machine coding questions.

Build an Autocomplete / Typeahead Search

Interview Question: "Build a search input with autocomplete suggestions from an API. Support keyboard navigation and debounced requests."

Requirements

  • Debounced API calls on input (300ms)
  • Display suggestion dropdown
  • Keyboard navigation (ArrowDown/Up, Enter, Escape)
  • Highlight matching text in suggestions
  • Cancel in-flight requests when input changes
  • Accessible (ARIA combobox pattern)

Implementation

function Autocomplete<T>({
  fetchSuggestions,
  renderItem,
  getKey,
  getLabel,
  onSelect,
  placeholder = 'Search...',
  debounceMs = 300,
}: {
  fetchSuggestions: (query: string, signal: AbortSignal) => Promise<T[]>;
  renderItem: (item: T, query: string) => ReactNode;
  getKey: (item: T) => string;
  getLabel: (item: T) => string;
  onSelect: (item: T) => void;
  placeholder?: string;
  debounceMs?: number;
}) {
  const [query, setQuery] = useState('');
  const [suggestions, setSuggestions] = useState<T[]>([]);
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const [loading, setLoading] = useState(false);
  const abortRef = useRef<AbortController | null>(null);
  const listRef = useRef<HTMLUListElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const listboxId = useId();
 
  const debouncedFetch = useMemo(
    () =>
      debounce(async (q: string) => {
        abortRef.current?.abort();
        if (!q.trim()) {
          setSuggestions([]);
          setIsOpen(false);
          return;
        }
 
        const controller = new AbortController();
        abortRef.current = controller;
        setLoading(true);
 
        try {
          const results = await fetchSuggestions(q, controller.signal);
          setSuggestions(results);
          setIsOpen(results.length > 0);
          setActiveIndex(-1);
        } catch (err) {
          if ((err as Error).name !== 'AbortError') {
            setSuggestions([]);
          }
        } finally {
          setLoading(false);
        }
      }, debounceMs),
    [fetchSuggestions, debounceMs]
  );
 
  const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
    setQuery(e.target.value);
    debouncedFetch(e.target.value);
  };
 
  const handleSelect = (item: T) => {
    setQuery(getLabel(item));
    setIsOpen(false);
    onSelect(item);
  };
 
  const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
    if (!isOpen) return;
 
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setActiveIndex(i => Math.min(i + 1, suggestions.length - 1));
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex(i => Math.max(i - 1, 0));
        break;
      case 'Enter':
        if (activeIndex >= 0) {
          e.preventDefault();
          handleSelect(suggestions[activeIndex]);
        }
        break;
      case 'Escape':
        setIsOpen(false);
        setActiveIndex(-1);
        break;
    }
  };
 
  useEffect(() => {
    return () => abortRef.current?.abort();
  }, []);
 
  const activeDescendant =
    activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
 
  return (
    <div className="autocomplete" role="combobox" aria-expanded={isOpen}>
      <input
        ref={inputRef}
        type="text"
        value={query}
        onChange={handleInputChange}
        onKeyDown={handleKeyDown}
        onFocus={() => suggestions.length > 0 && setIsOpen(true)}
        onBlur={() => setTimeout(() => setIsOpen(false), 200)}
        placeholder={placeholder}
        role="combobox"
        aria-autocomplete="list"
        aria-controls={listboxId}
        aria-activedescendant={activeDescendant}
      />
      {loading && <span className="spinner" aria-hidden="true" />}
      {isOpen && (
        <ul ref={listRef} id={listboxId} role="listbox">
          {suggestions.map((item, index) => (
            <li
              key={getKey(item)}
              id={`${listboxId}-option-${index}`}
              role="option"
              aria-selected={index === activeIndex}
              className={index === activeIndex ? 'active' : ''}
              onMouseDown={() => handleSelect(item)}
              onMouseEnter={() => setActiveIndex(index)}
            >
              {renderItem(item, query)}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Highlight Matching Text

function HighlightMatch({ text, query }: { text: string; query: string }) {
  if (!query.trim()) return <>{text}</>;
  const regex = new RegExp(`(${escapeRegex(query)})`, 'gi');
  const parts = text.split(regex);
 
  return (
    <>
      {parts.map((part, i) =>
        regex.test(part) ? <mark key={i}>{part}</mark> : part
      )}
    </>
  );
}
 
function escapeRegex(str: string) {
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

Caching Layer (Optional Enhancement)

function createCache<T>(maxAge = 60_000) {
  const cache = new Map<string, { data: T; timestamp: number }>();
 
  return {
    get(key: string): T | undefined {
      const entry = cache.get(key);
      if (!entry) return undefined;
      if (Date.now() - entry.timestamp > maxAge) {
        cache.delete(key);
        return undefined;
      }
      return entry.data;
    },
    set(key: string, data: T) {
      cache.set(key, { data, timestamp: Date.now() });
    },
  };
}

What Interviewers Look For

  1. Debouncing — Not firing on every keystroke
  2. Race condition handling — AbortController to cancel stale requests
  3. Keyboard accessibility — Full ArrowDown/Up/Enter/Escape support with aria-activedescendant
  4. Performance — Caching results, not re-fetching for repeated queries
  5. Edge cases — Empty query, rapid typing, blur/focus management