Typeahead With Keyboard Navigation
by Pratik Rai
Read the full write-upA city search that queries as you type, once you have entered at least three characters.
The search function has deliberately variable latency, so responses come back out of order — making sure a slow earlier request never overwrites a newer one is the point of the exercise.
Requirements
Search only once the query is at least
MIN_CHARSlong; below that, clear the results.Debounce so a request is not made for every keystroke.
A response for a query that is no longer current must never be shown — abort it.
Arrow Up and Arrow Down move through results, Enter selects, Escape closes.
Selecting fills the input and closes the list without immediately searching again.
The input is a
role="combobox"witharia-expandedandaria-activedescendant; results are arole="listbox"of options.
Notes
searchCitiesaccepts anAbortSignaland rejects with anAbortErrorwhen cancelled.Latency is randomised between roughly 200 and 800ms, so out-of-order responses happen on their own — type quickly and watch.
Hints
Typeahead With Keyboard Navigation (reference solution)
One way to do it, not the only way. Yours passes if it renders.
Solution files
1import { useState, useEffect, useRef } from 'react';
2
3const CITIES = [
4 'Bengaluru', 'Berlin', 'Bern', 'Boston', 'Bristol',
5 'Chennai', 'Chicago', 'Copenhagen',
6 'Delhi', 'Denver', 'Dublin',
7 'Kolkata', 'Kyoto',
8 'Lisbon', 'London', 'Los Angeles',
9 'Madrid', 'Melbourne', 'Mumbai', 'Munich',
10 'Nairobi', 'New York',
11 'Osaka', 'Oslo',
12 'Paris', 'Prague', 'Pune',
13 'Seattle', 'Seoul', 'Singapore', 'Sydney',
14 'Tokyo', 'Toronto',
15];
16
17/**
18 * Stands in for a network call, including the part that makes this hard:
19 * responses come back in an unpredictable order, so a slow early request can
20 * land after a fast later one.
21 */
22function searchCities(query, { signal } = {}) {
23 const latency = 200 + Math.random() * 600;
24 return new Promise((resolve, reject) => {
25 const timer = setTimeout(() => {
26 const q = query.toLowerCase();
27 resolve(CITIES.filter((city) => city.toLowerCase().includes(q)));
28 }, latency);
29
30 if (signal) {
31 signal.addEventListener('abort', () => {
32 clearTimeout(timer);
33 reject(new DOMException('Aborted', 'AbortError'));
34 });
35 }
36 });
37}
38
39const MIN_CHARS = 3;
40const DEBOUNCE_MS = 300;
41
42export default function App() {
43 const [query, setQuery] = useState('');
44 const [results, setResults] = useState([]);
45 const [loading, setLoading] = useState(false);
46 const [open, setOpen] = useState(false);
47 const [activeIndex, setActiveIndex] = useState(0);
48 const [chosen, setChosen] = useState(null);
49
50 // Set when a result is picked, so the effect can tell "the query changed
51 // because the user typed" from "the query changed because we filled it in".
52 const skipNextSearch = useRef(false);
53
54 useEffect(() => {
55 if (skipNextSearch.current) {
56 skipNextSearch.current = false;
57 return undefined;
58 }
59
60 const trimmed = query.trim();
61 if (trimmed.length < MIN_CHARS) {
62 setResults([]);
63 setOpen(false);
64 setLoading(false);
65 return undefined;
66 }
67
68 // Two layers, doing two different jobs. The timer stops a request being
69 // made for every keystroke; the controller discards a request already in
70 // flight when the query moves on. Without the second, a slow early
71 // response can land after a fast later one and overwrite it.
72 const controller = new AbortController();
73 setLoading(true);
74
75 const timer = setTimeout(() => {
76 searchCities(trimmed, { signal: controller.signal })
77 .then((found) => {
78 setResults(found);
79 setActiveIndex(0);
80 setOpen(true);
81 setLoading(false);
82 })
83 .catch((error) => {
84 // An abort is the expected outcome of typing another letter, not a
85 // failure worth showing anybody.
86 if (error.name !== 'AbortError') setLoading(false);
87 });
88 }, DEBOUNCE_MS);
89
90 return () => {
91 clearTimeout(timer);
92 controller.abort();
93 };
94 }, [query]);
95
96 const choose = (index) => {
97 const city = results[index];
98 if (!city) return;
99 setChosen(city);
100 skipNextSearch.current = true;
101 setQuery(city);
102 setOpen(false);
103 };
104
105 const onKeyDown = (event) => {
106 if (!open || results.length === 0) return;
107
108 if (event.key === 'ArrowDown') {
109 event.preventDefault();
110 setActiveIndex((i) => (i + 1) % results.length);
111 } else if (event.key === 'ArrowUp') {
112 event.preventDefault();
113 setActiveIndex((i) => (i - 1 + results.length) % results.length);
114 } else if (event.key === 'Enter') {
115 event.preventDefault();
116 choose(activeIndex);
117 } else if (event.key === 'Escape') {
118 event.preventDefault();
119 setOpen(false);
120 }
121 };
122
123 return (
124 <div className="search">
125 <h1>Find a city</h1>
126
127 <div className="field">
128 <input
129 value={query}
130 onChange={(e) => setQuery(e.target.value)}
131 onKeyDown={onKeyDown}
132 placeholder="Type at least 3 letters…"
133 aria-label="City"
134 role="combobox"
135 aria-expanded={open}
136 aria-controls="city-results"
137 aria-activedescendant={
138 open && results[activeIndex] ? 'city-' + results[activeIndex] : undefined
139 }
140 />
141
142 {open && results.length > 0 && (
143 <ul className="results" id="city-results" role="listbox">
144 {results.map((city, index) => (
145 <li
146 key={city}
147 id={'city-' + city}
148 role="option"
149 aria-selected={index === activeIndex}
150 className={'result' + (index === activeIndex ? ' result-active' : '')}
151 onPointerEnter={() => setActiveIndex(index)}
152 onClick={() => choose(index)}
153 >
154 {city}
155 </li>
156 ))}
157 </ul>
158 )}
159 </div>
160
161 {loading ? (
162 <p className="status">Searching…</p>
163 ) : query.trim().length >= MIN_CHARS && results.length === 0 ? (
164 <p className="status">No matches.</p>
165 ) : (
166 <p className="hint">Type 3 or more letters to search.</p>
167 )}
168
169 {chosen && (
170 <p className="chosen">
171 Selected: <strong>{chosen}</strong>
172 </p>
173 )}
174 </div>
175 );
176}How it works
Most candidates debounce, see a working search, and stop. The randomised latency here exists to break that version in front of you: with a debounce alone, a slow request for lon can resolve after a fast one for lond and put the wrong list on screen. It is a genuine production bug and it is invisible on a fast connection, which is exactly why it gets asked.
So there are two mechanisms doing two jobs. The timer reduces how many requests you make. The AbortController makes sure a request whose query is no longer current cannot affect state. Both are undone in the same effect cleanup, which is what makes the pairing natural — React tears down the previous effect before running the next, so "cancel whatever the last keystroke started" is the cleanup, not extra bookkeeping.
The alternative to aborting is a staleness guard: capture the query, and on resolve compare it with the current one before calling setResults. It works, and it is the right answer where you cannot cancel. Aborting is better where you can, because it stops the work rather than discarding it.
The selection loop is the subtle one. Writing the chosen city into the input changes query, the effect reruns, and the dropdown reopens over the thing just selected. A ref checked at the top of the effect breaks the cycle. Reaching for a ref rather than more state is right here because nothing renders differently as a result of it.
Against the existing debounce-and-search problem, the additions are keyboard navigation, cancellation and the combobox roles — the parts that separate a demo from a control someone can actually use.