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
- Debouncing â Not firing on every keystroke
- Race condition handling â AbortController to cancel stale requests
- Keyboard accessibility â Full ArrowDown/Up/Enter/Escape support with
aria-activedescendant - Performance â Caching results, not re-fetching for repeated queries
- Edge cases â Empty query, rapid typing, blur/focus management