Learn the concept
Data Layer Architecture
An autocomplete component requires debounced API calls, client-side caching (LRU), keyboard navigation, ARIA combobox accessibility, and virtualization for long result lists to deliver a responsive, inclusive search experience.
Autocomplete is the most frequently asked frontend system design question in interviews at Google, Amazon, and Meta. It tests your ability to balance performance, accessibility, and user experience in a single component.
Before diving into implementation, clarify these requirements:
Functional Requirements:
Non-Functional Requirements:
The data flow follows this pattern:
GET /api/search?q={query}&limit=10Why debounce? Without it, typing "react hooks" fires 11 API calls (one per keystroke). With a 300ms debounce, it fires 1-2 calls.
An LRU (Least Recently Used) cache stores recent query results to avoid redundant API calls:
GET /api/search?q=react&limit=10&category=docs
Response:
{
"results": [
{ "id": "1", "title": "React Hooks", "category": "docs", "highlight": "<mark>React</mark> Hooks" },
{ "id": "2", "title": "React Router", "category": "library", "highlight": "<mark>React</mark> Router" }
],
"total": 142,
"queryTime": 12
}Key decisions:
limit parameter for paginationtotal for "showing X of Y results" UI| Key | Action | |-----|--------| | ArrowDown | Move focus to next result | | ArrowUp | Move focus to previous result | | Enter | Select the focused result | | Escape | Close the dropdown, clear focus | | Tab | Close dropdown, move focus to next form element |
Track the activeIndex in state. On arrow key press, update the index and scroll the active item into view.
The WAI-ARIA combobox pattern is the standard for autocomplete:
role="combobox", aria-expanded, aria-autocomplete="list", aria-controls pointing to the listbox IDrole="listbox"role="option" with a unique IDaria-activedescendant on the input points to the currently focused option's IDaria-live="polite"For long result lists (100+ items), render only visible items using virtualization (react-window or @tanstack/virtual):
inputmode="search" for the search keyboard on mobiledangerouslySetInnerHTML with user input for highlighting — use server-provided safe highlights or a sanitizerimport { useState, useRef, useCallback, useEffect } from 'react';
interface SearchResult {
id: string;
title: string;
highlight: string;
}
// Simple LRU cache implementation
class LRUCache<T> {
private cache = new Map<string, { data: T; timestamp: number }>();
constructor(private maxSize = 50, private ttlMs = 5 * 60 * 1000) {}
get(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() - entry.timestamp > this.ttlMs) {
this.cache.delete(key);
return null;
}
// Move to end (most recently used)
this.cache.delete(key);
this.cache.set(key, entry);
return entry.data;
}
set(key: string, data: T): void {
this.cache.delete(key);
if (this.cache.size >= this.maxSize) {
// Delete the oldest (first) entry
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) this.cache.delete(firstKey);
}
this.cache.set(key, { data, timestamp: Date.now() });
}
}
const searchCache = new LRUCache<SearchResult[]>();
export function useDebouncedSearch(delay = 300) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const search = useCallback((searchQuery: string) => {
// Clear previous debounce timer
clearTimeout(timerRef.current);
const trimmed = searchQuery.trim().toLowerCase();
if (!trimmed) {
setResults([]);
setIsLoading(false);
return;
}
// Check cache first
const cached = searchCache.get(trimmed);
if (cached) {
setResults(cached);
setIsLoading(false);
return;
}
setIsLoading(true);
timerRef.current = setTimeout(async () => {
// Cancel any in-flight request
abortRef.current?.abort();
abortRef.current = new AbortController();
try {
const res = await fetch(
`/api/search?q=${encodeURIComponent(trimmed)}&limit=10`,
{ signal: abortRef.current.signal }
);
const data = await res.json();
searchCache.set(trimmed, data.results);
setResults(data.results);
} catch (err) {
if ((err as Error).name !== 'AbortError') {
console.error('Search failed:', err);
setResults([]);
}
} finally {
setIsLoading(false);
}
}, delay);
}, [delay]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearTimeout(timerRef.current);
abortRef.current?.abort();
};
}, []);
return { query, setQuery: (q: string) => { setQuery(q); search(q); }, results, isLoading };
}Amazon, Shopify, and other e-commerce platforms use autocomplete to suggest products, categories, and brands as users type, reducing time to purchase and improving conversion rates
VS Code and JetBrains IDEs use typeahead to suggest variable names, function signatures, and API methods, combining local analysis with language server protocol responses
Google Maps and Mapbox provide address autocomplete components that reduce form abandonment by 20-30% compared to free-text address fields
Build an autocomplete that searches GitHub repositories via the public API with debouncing, caching, and keyboard navigation. Display repo name, stars, and language in results.
Create an autocomplete that searches across multiple categories (users, posts, tags) simultaneously, displaying grouped results with category headers and custom icons.
Google Search autocomplete predicts queries using historical search data, trending topics, and personalized suggestions, processing billions of queries with sub-100ms response times
Provides search-as-a-type infrastructure with client-side InstantSearch libraries that handle debouncing, caching, and highlighting out of the box
Uses typeahead extensively for @mentions, channel search, and slash commands with fuzzy matching and contextual ranking