77Build a typeahead/autocomplete with caching and keyboard support
Problem
Build <Autocomplete fetchSuggestions={fetchSuggestions} onSelect={onSelect} debounceMs={200} />. fetchSuggestions(query) resolves to an array of strings.
Follow the ARIA combobox pattern:
<input role="combobox" aria-autocomplete="list" aria-expanded aria-controls="{listboxId}" aria-activedescendant="{activeOptionId}"><ul role="listbox" id="{listboxId}">with<li role="option" id="…" aria-selected="true|false">for each suggestion, shown only while open.
Requirements
- Debounce: fetch only after the user stops typing for
debounceMs. Do not fetch for an empty (trimmed) query. - Cache results by query: typing a query you already searched shows the cached results without a request.
- Ignore responses for queries that are no longer current.
ArrowDownandArrowUpmove the active option (wrapping); the active option hasaria-selected="true"and its id is the input'saria-activedescendant.Enterselects the active option: put it in the input, close the list and callonSelect(value). Clicking an option does the same.Escapecloses the list.
Try it yourself
Write your solution, run it, then submit to check it against the tests.
Video walkthrough
Stuck? Watch someone solve it, then try again on your own.
Step by step
Build the solution one idea at a time.
- 1
State
text(the input value),suggestions,open, andactiveIndex(-1 when nothing is active). Acacheref holds aMapfrom query to results, and alatestQueryref remembers which query the UI is waiting for. - 2
Debounced, cached fetching
An effect on
[text]starts a timer. When it fires: trim the query; if it is empty, close and stop. If the cache has it, show the cached results. Otherwise remember it aslatestQuery, fetch, store the response in the cache, and only show it if the query is stilllatestQuery. - 3
Keyboard handling on the input
ArrowDownopens the list and movesactiveIndexforward, wrapping to 0;ArrowUpmoves back, wrapping to the end.Enterselects the active option.Escapecloses. CallpreventDefaultfor the arrows so the cursor does not jump. - 4
Point screen readers at the active option
Focus stays in the input the whole time.
aria-activedescendantnames the active option's id, which makes screen readers announce it as if it were focused. Build option ids fromuseId()so several autocompletes on a page do not clash. - 5
Selecting with the mouse
Use
onMouseDownwithpreventDefaulton options instead ofonClick. The input would otherwise lose focus (blur) before the click registers, which some implementations use to close the list. - 6
Commit a selection
select(value): settextto the value, close the list, resetactiveIndex, and callonSelect. Mark the value as "just selected" so the effect does not immediately search for it again.
Approach
A debounced effect turns typing into cached, race-safe lookups; the input owns keyboard handling and exposes the active option through aria-activedescendant, which is the standard accessible combobox.
Solution
import { useEffect, useId, useRef, useState } from "react";
function Autocomplete({ fetchSuggestions, onSelect = () => {}, debounceMs = 200 }) {
const id = useId();
const listboxId = `${id}-listbox`;
const optionId = (i) => `${id}-option-${i}`;
const [text, setText] = useState("");
const [suggestions, setSuggestions] = useState([]);
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const cache = useRef(new Map());
const latestQuery = useRef("");
const skipNextSearch = useRef(false);
useEffect(() => {
if (skipNextSearch.current) {
skipNextSearch.current = false;
return;
}
const timer = setTimeout(() => {
const query = text.trim();
latestQuery.current = query;
if (!query) {
setSuggestions([]);
setOpen(false);
return;
}
const show = (list) => {
if (latestQuery.current !== query) return; // a newer query owns the list
setSuggestions(list);
setActiveIndex(-1);
setOpen(true);
};
if (cache.current.has(query)) {
show(cache.current.get(query));
return;
}
fetchSuggestions(query).then((list) => {
cache.current.set(query, list);
show(list);
});
}, debounceMs);
return () => clearTimeout(timer);
}, [text, debounceMs, fetchSuggestions]);
function select(value) {
skipNextSearch.current = true;
setText(value);
setOpen(false);
setActiveIndex(-1);
onSelect(value);
}
function onKeyDown(event) {
const count = suggestions.length;
if (event.key === "ArrowDown" && count > 0) {
event.preventDefault();
setOpen(true);
setActiveIndex((i) => (i + 1) % count);
} else if (event.key === "ArrowUp" && count > 0) {
event.preventDefault();
setOpen(true);
setActiveIndex((i) => (i <= 0 ? count - 1 : i - 1));
} else if (event.key === "Enter" && open && activeIndex >= 0) {
event.preventDefault();
select(suggestions[activeIndex]);
} else if (event.key === "Escape") {
setOpen(false);
setActiveIndex(-1);
}
}
const expanded = open && suggestions.length > 0;
return (
<div style={{ position: "relative", width: 280 }}>
<input
role="combobox"
aria-autocomplete="list"
aria-expanded={expanded}
aria-controls={listboxId}
aria-activedescendant={expanded && activeIndex >= 0 ? optionId(activeIndex) : undefined}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Search a city"
style={{ width: "100%" }}
/>
{expanded && (
<ul role="listbox" id={listboxId} style={{ listStyle: "none", margin: 0, padding: 4, border: "1px solid #d2d2d7", borderRadius: 8 }}>
{suggestions.map((item, i) => (
<li
key={item}
id={optionId(i)}
role="option"
aria-selected={i === activeIndex}
onMouseDown={(e) => {
e.preventDefault();
select(item);
}}
style={{ padding: "4px 8px", borderRadius: 6, background: i === activeIndex ? "#e8f0fe" : "transparent", cursor: "pointer" }}
>
{item}
</li>
))}
</ul>
)}
</div>
);
}
// Preview
const CITIES = ["Ahmedabad", "Bengaluru", "Bhopal", "Chennai", "Delhi", "Hyderabad", "Jaipur", "Kolkata", "Lucknow", "Mumbai", "Pune", "Surat"];
const fakeFetch = (q) => new Promise((resolve) => setTimeout(() => resolve(CITIES.filter((c) => c.toLowerCase().startsWith(q.toLowerCase()))), 300));
function App() {
const [picked, setPicked] = useState("");
return (
<div>
<Autocomplete fetchSuggestions={fakeFetch} onSelect={setPicked} />
<p>Selected: {picked}</p>
</div>
);
}Complexity
Time: one request per distinct query after a pause; O(k) to render k suggestions. Space: O(distinct queries × results) for the cache.
Edge cases
- Fast typing sends one request for the final text.
- Going back to a previous query uses the cache instantly.
- A slow response for an old query never replaces newer suggestions.
- Arrow keys wrap around; Enter with no active option does nothing.
- Selecting an option does not trigger a new search for the selected text.
Follow-up questions
Answer each one out loud first, then reveal the model answer.