- React
- Live Machine Coding Challenges and Custom Hooks
- Machine Coding: Autocomplete Typeahead Search
Machine Coding: Autocomplete Typeahead Search
A production Autocomplete Typeahead features debounced asynchronous fetching, in-memory caching to eliminate duplicate API requests, full keyboard navigation (ArrowDown, ArrowUp, Enter, Escape), WAI-ARIA combobox accessibility, and click-outside dismissal via ref.
Key Learning Objectives
Implement an Autocomplete Typeahead with debounced async queries and request caching.
Implement keyboard navigation (ArrowUp, ArrowDown, Enter, Escape) with active item highlighting.
Apply WAI-ARIA Combobox 1.2 specifications (role="combobox", aria-autocomplete="list", aria-expanded).
Handle race conditions and out-of-order network responses using AbortController or query sequence IDs.
The Interview Problem & Context
Implement a production `<Autocomplete onSearch={async (query) => ...} onSelect={(item) => ...} />` component with debouncing, keyboard navigation, caching, and click-outside dismissal.
1import React, { useState, useEffect, useRef, KeyboardEvent } from 'react';23interface AutocompleteProps<T> {4 onSearch: (query: string) => Promise<T[]>;5 onSelect: (item: T) => void;6 getLabel: (item: T) => string;7 placeholder?: string;8}910export function Autocomplete<T>({11 onSearch,12 onSelect,13 getLabel,14 placeholder = 'Search...',15}: AutocompleteProps<T>) {16 const [query, setQuery] = useState('');17 const [results, setResults] = useState<T[]>([]);18 const [isOpen, setIsOpen] = useState(false);19 const [isLoading, setIsLoading] = useState(false);20 const [highlightedIndex, setHighlightedIndex] = useState(-1);2122 const containerRef = useRef<HTMLDivElement>(null);23 const cacheRef = useRef<Map<string, T[]>>(new Map());2425 // 1. Debounced async search with in-memory caching26 useEffect(() => {27 const trimmed = query.trim();28 if (!trimmed) {29 setResults([]);30 setIsOpen(false);31 return;32 }3334 if (cacheRef.current.has(trimmed)) {35 setResults(cacheRef.current.get(trimmed)!);36 setIsOpen(true);37 return;38 }3940 const timer = setTimeout(async () => {41 setIsLoading(true);42 try {43 const data = await onSearch(trimmed);44 cacheRef.current.set(trimmed, data);45 setResults(data);46 setIsOpen(true);47 setHighlightedIndex(-1);48 } catch (err) {49 console.error('Search error:', err);50 } finally {51 setIsLoading(false);52 }53 }, 300);5455 return () => clearTimeout(timer);56 }, [query, onSearch]);5758 // 2. Dismiss on click outside59 useEffect(() => {60 const handleClickOutside = (e: MouseEvent) => {61 if (containerRef.current && !containerRef.current.contains(e.target as Node)) {62 setIsOpen(false);63 }64 };65 document.addEventListener('mousedown', handleClickOutside);66 return () => document.removeEventListener('mousedown', handleClickOutside);67 }, []);6869 // 3. Keyboard navigation70 const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {71 if (!isOpen || results.length === 0) return;7273 switch (e.key) {74 case 'ArrowDown':75 e.preventDefault();76 setHighlightedIndex((prev) => (prev + 1) % results.length);77 break;78 case 'ArrowUp':79 e.preventDefault();80 setHighlightedIndex((prev) => (prev - 1 + results.length) % results.length);81 break;82 case 'Enter':83 e.preventDefault();84 if (highlightedIndex >= 0 && highlightedIndex < results.length) {85 handleItemClick(results[highlightedIndex]);86 }87 break;88 case 'Escape':89 e.preventDefault();90 setIsOpen(false);91 break;92 }93 };9495 const handleItemClick = (item: T) => {96 setQuery(getLabel(item));97 setIsOpen(false);98 onSelect(item);99 };100101 return (102 <div ref={containerRef} className="autocomplete-container" style={{ position: 'relative', width: '300px' }}>103 <input104 type="text"105 role="combobox"106 aria-autocomplete="list"107 aria-expanded={isOpen}108 value={query}109 placeholder={placeholder}110 onChange={(e) => setQuery(e.target.value)}111 onFocus={() => results.length > 0 && setIsOpen(true)}112 onKeyDown={handleKeyDown}113 className="autocomplete-input"114 style={{ width: '100%', padding: '8px 12px', boxSizing: 'border-box' }}115 />116117 {isOpen && (118 <ul119 role="listbox"120 className="autocomplete-dropdown"121 style={{122 position: 'absolute',123 top: '100%',124 left: 0,125 right: 0,126 background: '#fff',127 border: '1px solid #ccc',128 listStyle: 'none',129 margin: 0,130 padding: 0,131 maxHeight: '200px',132 overflowY: 'auto',133 zIndex: 1000,134 }}135 >136 {isLoading && <li style={{ padding: '8px' }}>Searching...</li>}137 {!isLoading && results.length === 0 && <li style={{ padding: '8px' }}>No matches found</li>}138 {!isLoading &&139 results.map((item, index) => (140 <li141 key={index}142 role="option"143 aria-selected={highlightedIndex === index}144 onClick={() => handleItemClick(item)}145 onMouseEnter={() => setHighlightedIndex(index)}146 style={{147 padding: '8px 12px',148 cursor: 'pointer',149 backgroundColor: highlightedIndex === index ? '#f3f4f6' : 'transparent',150 }}151 >152 {getLabel(item)}153 </li>154 ))}155 </ul>156 )}157 </div>158 );159}
Deep Architectural Explanation
The Complete Architecture of an Autocomplete Component
1. In-Memory Request Caching:
Using cacheRef = useRef(new Map<string, T[]>()):
- When user types "react", fetch data and cache it: cache.set("react", data).
- If user types "reacts" and then backspaces back to "react", the result is served instantly from cache with zero network latency!
- Because cacheRef is a ref, mutating it does not cause extra re-renders.
2. Keyboard Navigation Mechanics:
- ArrowDown: increments highlightedIndex with wrap-around using modulo (% results.length).
- ArrowUp: decrements highlightedIndex.
- Enter: selects the currently highlighted option.
- Escape: dismisses the dropdown menu.
- e.preventDefault() on arrow keys prevents the browser cursor from jumping to the start/end of the text input.
3. Combobox Accessibility:
- Input element: role="combobox", aria-autocomplete="list", and aria-expanded={isOpen}.
- Dropdown: role="listbox".
- Items: role="option" and aria-selected={highlightedIndex === index}.
Common Interview Traps & Pitfalls
Not preventing default on arrow keys, causing the text cursor to jump to the beginning of the input field.
Forgetting cleanup on the click-outside event listener, leaking memory on unmount.
Failing to handle network race conditions when fast responses arrive after earlier slow requests.
Senior Interviewer Follow-Up Probes
How would you bold/highlight matching query substrings within the rendered option labels?
How do you support multi-select tagging / token input (like Gmail recipient picker)?
How do you scroll the highlighted dropdown item into view automatically using `scrollIntoView`?
Frequently Asked Interview Questions
How do you prevent network race conditions in search inputs?
Use AbortController inside useEffect to cancel the in-flight request when the query changes, or track an active request timestamp/ID to discard stale responses.
Why is useRef preferred over useState for the search cache?
Because mutating the cache does not require a re-render. Reading and writing to cacheRef.current is synchronous and does not trigger component lifecycle overhead.
What is the WAI-ARIA role for an autocomplete input?
role="combobox" with aria-autocomplete="list", aria-expanded (indicating dropdown visibility), and aria-controls linking to the listbox ID.
How does click-outside detection work in React?
Attach a mousedown listener to document. Inside the callback, check if containerRef.current.contains(e.target). If false, close the dropdown.
How do you prevent the input cursor from moving when pressing ArrowUp/ArrowDown?
Call e.preventDefault() inside the onKeyDown handler when handling arrow keys, stopping native browser cursor movement.
