Learn the concept
Hooks
An autocomplete component debounces user input, fetches matching suggestions from an API, renders a dropdown with keyboard navigation (arrow keys + Enter), highlights the matched portion of each suggestion, and handles edge cases like race conditions and empty states.
Core Architecture:
useState for the query stringuseDebounce hook or setTimeout with cleanup.<ul> below the input, shown when there are results and the input is focusedactiveIndex with arrow keys, select on Enter, close on Escape<mark> or <strong>Debouncing Strategy:
The simplest approach uses useEffect with a setTimeout:
useEffect(() => {
const timer = setTimeout(() => fetchSuggestions(query), 300);
return () => clearTimeout(timer);
}, [query]);
This cancels the previous timer whenever the query changes, effectively debouncing.
Race Condition Handling: If the user types "rea" then quickly "reac", both API calls fire. The "rea" response might arrive after "reac", showing stale results. Solutions:
AbortController to cancel the previous requestcancelled flag in the cleanup functionKeyboard Navigation:
ArrowDown — increment activeIndex (wrap to 0 at end)ArrowUp — decrement activeIndex (wrap to last at start)Enter — select the item at activeIndexEscape — close the dropdown and clear activeIndexscrollIntoView()Accessibility:
role="combobox", aria-expanded, aria-activedescendant, aria-controlsrole="listbox" with unique idrole="option", aria-selected for active itemaria-live regionPerformance:
useMemo for the highlight computation if the list is largeuseRef mapimport { useState, useEffect, useRef, useCallback } from 'react';
function Autocomplete({ fetchSuggestions, onSelect, placeholder = 'Search...' }) {
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState([]);
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const [loading, setLoading] = useState(false);
const listRef = useRef(null);
const abortRef = useRef(null);
// Debounced fetch
useEffect(() => {
if (!query.trim()) {
setSuggestions([]);
setIsOpen(false);
return;
}
const timer = setTimeout(async () => {
// Cancel previous request
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
try {
const results = await fetchSuggestions(query, controller.signal);
if (!controller.signal.aborted) {
setSuggestions(results);
setIsOpen(results.length > 0);
setActiveIndex(-1);
}
} catch (err) {
if (err.name !== 'AbortError') console.error(err);
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}, 300);
return () => clearTimeout(timer);
}, [query, fetchSuggestions]);
const handleKeyDown = useCallback((e) => {
if (!isOpen) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(i => (i + 1) % suggestions.length);
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(i => (i - 1 + suggestions.length) % suggestions.length);
break;
case 'Enter':
if (activeIndex >= 0) {
e.preventDefault();
selectItem(suggestions[activeIndex]);
}
break;
case 'Escape':
setIsOpen(false);
setActiveIndex(-1);
break;
}
}, [isOpen, activeIndex, suggestions]);
const selectItem = (item) => {
setQuery(item.label);
setIsOpen(false);
onSelect?.(item);
};
// Highlight matching text
const highlightMatch = (text, query) => {
const idx = text.toLowerCase().indexOf(query.toLowerCase());
if (idx === -1) return text;
return (
<>
{text.slice(0, idx)}
<strong style={{ background: '#fef08a' }}>
{text.slice(idx, idx + query.length)}
</strong>
{text.slice(idx + query.length)}
</>
);
};
const listId = 'autocomplete-listbox';
const activeId = activeIndex >= 0 ? `option-${activeIndex}` : undefined;
return (
<div style={{ position: 'relative' }}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => suggestions.length > 0 && setIsOpen(true)}
onBlur={() => setTimeout(() => setIsOpen(false), 150)}
placeholder={placeholder}
role="combobox"
aria-expanded={isOpen}
aria-controls={listId}
aria-activedescendant={activeId}
aria-autocomplete="list"
/>
{loading && <span aria-live="polite">Loading...</span>}
{isOpen && (
<ul id={listId} role="listbox" ref={listRef}
style={{ position: 'absolute', top: '100%', left: 0, right: 0,
border: '1px solid #ddd', background: '#fff',
listStyle: 'none', margin: 0, padding: 0, maxHeight: 240,
overflowY: 'auto', zIndex: 10 }}>
{suggestions.map((item, i) => (
<li key={item.id ?? i} id={`option-${i}`}
role="option" aria-selected={i === activeIndex}
onMouseDown={() => selectItem(item)}
onMouseEnter={() => setActiveIndex(i)}
style={{
padding: '8px 12px', cursor: 'pointer',
background: i === activeIndex ? '#f0f0f0' : 'transparent'
}}>
{highlightMatch(item.label, query)}
</li>
))}
</ul>
)}
</div>
);
}
// Usage with a mock API
const fetchCities = async (query, signal) => {
const res = await fetch(`/api/cities?q=${query}`, { signal });
const data = await res.json();
return data.map(c => ({ id: c.id, label: c.name }));
};
function SearchPage() {
return <Autocomplete fetchSuggestions={fetchCities}
onSelect={(city) => console.log('Selected:', city)} />;
}Food delivery apps show restaurant and dish suggestions as the user types, with results debounced to avoid hammering the search API.
E-commerce checkout forms use typeahead powered by Google Places or Mapbox APIs to speed up address entry.
Developer tools like VS Code, Figma, and Linear use autocomplete-style command palettes for quick navigation.
Build an autocomplete that searches GitHub users via the public API, shows avatars in results, and navigates to the profile on selection.
Extend autocomplete into a tags input where selected items appear as removable chips and the dropdown filters out already-selected items.
Autocomplete/autosuggestion with API integration is a confirmed Swiggy machine coding round question. Their search bar handles restaurant names, cuisines, and dish searches.