UI build: typeahead with debounce and race handling
Every write belongs to the newest request, and focus never leaves the input: the list moves under it, not into it.
Predict first
Before you read: predict
ExamHall's admin "Assign exam" box searches 30,000 exam titles. This draft uses useDebounced(text, 300), which returns text once it has stayed the same for 300 ms:
The admin types econ and stops, so the search for "econ" goes out. She types -201 and stops again, so the search for "econ-201" goes out too. The "econ" reply, 8 exams, arrives next, while "econ-201" has not answered.
What shows under the box right after the "econ" reply arrives?
function AssignExam({ search }) { const [text, setText] = useState(''); const query = useDebounced(text, 300); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { if (!query) return; let ignore = false; setItems([]); setLoading(true); search(query) .then((found) => { if (!ignore) setItems(found); }) .finally(() => setLoading(false)); return () => { ignore = true; }; }, [query]); return ( <> <input value={text} onChange={(e) => setText(e.target.value)} /> {loading ? <p>Searching…</p> : items.length ? <ul>{items.map((x) => <li key={x.id}>{x.title}</li>)}</ul> : <p>No exams match</p>} </> );}The 30-second version
A typeahead holds three things: the typed text, a debounced query, and the newest request's result. One effect per query aborts the previous request and guards every write it makes. The input is a labelled combobox that keeps DOM focus: arrows move aria-activedescendant, Enter accepts, Escape closes. A picked title is not a new query.
Every write belongs to the newest request, and focus never leaves the input: the list moves under it, not into it.
How it works
Step 1 of 11
text controls the input; a debounced query searches. React resets the DOM to state per event; a saved e.target stays the live input.In the kitchen
- the clerk hears every word as he says it →
text, updated on every keystroke (step 1) - the yard hand goes only after a pause → the debounced
querystarts the search (steps 1–2)
Analogy: A builders' merchant's trade counter
A builder describes the fitting he needs while the counter clerk listens to every word. She sends the yard hand for samples only once he pauses. When he adds a detail, she calls that errand off.
| At the counter | In the component |
|---|---|
| the clerk hears every word as he says it | text, updated on every keystroke (step 1) |
| the yard hand goes only after a pause | the debounced query starts the search (steps 1–2) |
| a tray from a called-off errand goes under the counter unopened, and nobody turns the "yard run out" card for it | a superseded request writes nothing (step 3) |
| the card reads "nothing in stock" only when the current errand comes back empty | status from the newest request (step 4) |
| she points at a sample with her pencil while he keeps talking to her | aria-activedescendant; focus stays in the input (step 7) |
| "that one" goes on his chit, and nobody is sent to the yard for it | a pick is not a query (step 10) |
Breaks down when: the clerk can read the errand's name on a tray; a reply carries no such label, so the code must know which request is current (the flag or the aborted signal). Wrong inference: "an old reply will look old".
Contrasting case: a native <select> is a select-only combobox: no typing, no search, and the browser supplies the keys, so there is nothing to debounce or race.
In real terms: a typeahead is a controlled input, a debounced query, one guarded request per query, and a listbox the input points into.
Examples, run for real
4.1 ExamHall: EXH-2870 and MR !3170
The pilot box searched the 30,000 titles 300 ms after the admin stopped typing, with no cleanup. The script sends "econ", then "econ-201", releases the "econ-201" reply first and the slow "econ" reply last, then presses Down and Enter. MR !3170 aborts the old request in the cleanup and guards every write after the reply (steps 2–3):
// EXH-2870, the pilot
useEffect(() => {
if (!query) return;
search(query).then((found) => { setItems(found); setOpen(true); });
}, [query]);
// MR !3170
useEffect(() => {
if (!query) return;
const ac = new AbortController();
search(query, { signal: ac.signal }).then((found) => {
if (ac.signal.aborted) return;
setItems(found);
setOpen(true);
}, () => {});
return () => ac.abort();
}, [query]);
4.1 catalogue 30000 titles · "econ" matches 1500 · "econ-201" matches 25 · a reply is the first 8
4.1 EXH-2870 pilot: sent econ, econ-201 · replies econ-201, then econ · the list shows "econ"'s 8, first ECON-101 Principles of Economics · Aug 2027 · Alexandria · Down, Enter assigns ECON-101 Principles of Economics · Aug 2027 · Alexandria
4.1 MR !3170: sent econ, econ-201 · econ aborted when econ-201 went out, so no econ reply lands · the list shows "econ-201"'s 8, first ECON-201 Intermediate Microeconomics · Aug 2027 · Alexandria · Down, Enter assigns ECON-201 Intermediate Microeconomics · Aug 2027 · Alexandria
The pilot listed "econ" under the text "econ-201", and Enter assigned a first-year exam. With the MR, "econ" closes when "econ-201" goes out.
4.2 Media streaming: the reference box on a podcast search
The reference (reference.jsx) labelled "Search episodes", on a listener's keys. The script reads the DOM in jsdom, then loads the same DOM into Chromium and reads its accessibility tree:
4.2 typed "jazz" · 1 search · reply 6 episodes · input role combobox, aria-expanded true, aria-controls → listbox "Search episodes"
4.2 Down, Down · DOM focus: the input · aria-activedescendant → option "Jazz standards, part 1" (aria-selected true) · Chromium: focused combobox "Search episodes", active descendant option "Jazz standards, part 1"
4.2 Enter · box "Jazz standards, part 1" · aria-expanded false · searches in the next 2000 ms: 0
4.2 a fresh "jazz" list, Down, Escape · aria-expanded false, box "jazz" · Escape again · box ""
DOM focus stays in the input; Chromium reports the highlighted episode as the combobox's active descendant (step 7). Enter starts no search (step 10); Escape closes, then clears (step 8).
4.3 The test list (build.test.mjs)
| Test | What it checks |
|---|---|
| T1 | a combobox named "Assign exam", aria-controls naming a listbox, list autocomplete, collapsed |
| T2 | the box shows every keystroke; one search, 300 ms after the last key |
| T3 | a stale reply, landing between and after the newest: searching until the newest answers; the newest page stays |
| T4 | Down twice: focus in the input; the active descendant is the second option, the only selected one |
| T5 | Enter accepts once, writes the title, collapses, and sends no new search |
| T6 | Escape collapses and keeps the text; a second Escape clears it |
| T7 | a new reply resets the highlight; Enter then picks nothing |
| T8 | no match, then a failed search: the right message, collapsed, nothing to pick |
| T9 | a superseded search is not a failure |
| T10 | two boxes on one page: each one's listbox and active descendant are its own |
4.3 test list on reference.jsx: 10 of 10 pass
4.3 test list on the reference with its "ac.signal.aborted" checks and its cleanup removed: fails T3, T9
Try it
Answer first, then check. Nothing is saved.
Question 1 of 8
A podcast app's episode search debounces its input's change handler with the kata-debounce reference, which saves each call's arguments and runs the last call once 250 ms pass with no call:
A listener types jazz, one key every 40 ms, then stops.
One second after the last key, what does the box show, and what has been searched?
function EpisodeSearch({ search }) { const [text, setText] = useState(''); const onChange = useMemo( () => debounce((e) => { setText(e.target.value); search(e.target.value); }, 250), [], ); return <input aria-label="Search episodes" value={text} onChange={onChange} />;}Question 2 of 8
A chat app's mention box:
A screen-reader user types sa, and three people are listed. She presses Down twice: the highlight moves on screen, but her reader announces nothing new. She then types m to narrow the list.
Which one change makes the highlighted person the one her reader announces, with the rest working as before?
function MentionBox({ people, onChange }) { const [text, setText] = useState(''); const [active, setActive] = useState(-1); const open = people.length > 0; function onKeyDown(e) { if (e.key === 'ArrowDown') setActive((i) => Math.min(i + 1, people.length - 1)); if (e.key === 'ArrowUp') setActive((i) => Math.max(i - 1, 0)); } return ( <> <input role="combobox" aria-label="Mention" aria-expanded={open} aria-controls="people" value={text} onChange={(e) => { setText(e.target.value); onChange(e.target.value); }} onKeyDown={onKeyDown} /> <ul id="people" role="listbox" aria-label="People" hidden={!open}> {people.map((p, i) => ( <li key={p.id} id={`person-${p.id}`} role="option" className={i === active ? 'hl' : ''}>{p.name}</li> ))} </ul> </> );}Question 3 of 8
A payments transfer form renders <PartyPicker label="Payer" /> and then <PartyPicker label="Payee" />, with the same useDebounced(text, 300):
A clerk types acme into Payer: Acme Holdings, Acme Retail and Acme Trust are listed. Without choosing one, she moves to Payee and types nile: Nile Bank, Nile Foods and Nile Freight are listed. She presses Down twice.
Which option does Chromium's accessibility tree give as the Payee box's active descendant, the one a screen reader announces?
function PartyPicker({ label, search }) { const [text, setText] = useState(''); const query = useDebounced(text, 300); const [items, setItems] = useState([]); const [active, setActive] = useState(-1); useEffect(() => { if (!query) return; const ac = new AbortController(); search(query, { signal: ac.signal }) .then((found) => { setItems(found); setActive(-1); }, () => {}); return () => ac.abort(); }, [query]); function onKeyDown(e) { if (e.key === 'ArrowDown') setActive((i) => Math.min(i + 1, items.length - 1)); if (e.key === 'ArrowUp') setActive((i) => Math.max(i - 1, 0)); } return ( <> <input role="combobox" aria-label={label} aria-expanded={items.length > 0} aria-controls="party-list" aria-activedescendant={active >= 0 ? `party-${active}` : undefined} value={text} onChange={(e) => setText(e.target.value)} onKeyDown={onKeyDown} /> <ul id="party-list" role="listbox" aria-label={label}> {items.map((p, i) => ( <li key={p.id} id={`party-${i}`} role="option" aria-selected={i === active}>{p.name}</li> ))} </ul> </> );}Question 4 of 8
A ticket site's event search, with the same useDebounced(text, 300):
A fan types nile and stops; 3 concerts are listed. She presses Down, then Enter, and the box reads "Nile Jazz Night · 12 Dec". The server answers each search as soon as it is sent. She then waits 2 seconds without touching anything.
At that point, how many searches has the box sent since Enter, and is the list showing?
function EventSearch({ search }) { const [text, setText] = useState(''); const query = useDebounced(text, 300); const [items, setItems] = useState([]); const [open, setOpen] = useState(false); const [active, setActive] = useState(-1); useEffect(() => { if (!query) return; const ac = new AbortController(); search(query, { signal: ac.signal }) .then((found) => { setItems(found); setActive(-1); setOpen(found.length > 0); }, () => {}); return () => ac.abort(); }, [query]); function onKeyDown(e) { if (e.key === 'ArrowDown') { setOpen(true); setActive((i) => Math.min(i + 1, items.length - 1)); } if (e.key === 'Enter' && open && active >= 0) { setText(items[active].title); setOpen(false); } } return ( <> <input role="combobox" aria-label="Find an event" aria-expanded={open} aria-controls="events" value={text} onChange={(e) => setText(e.target.value)} onKeyDown={onKeyDown} /> <ul id="events" role="listbox" aria-label="Events" hidden={!open}> {items.map((x, i) => <li key={x.id} id={`ev-${i}`} role="option" aria-selected={i === active}>{x.title}</li>)} </ul> </> );}Question 5 of 8
Time box: 45 minutes, a blank file. Say your test list aloud first. Then build ExamHall's "Assign exam" box as a React component ExamPicker({ search, onPick }): search(q, { signal }) resolves to [{ id, title }]; search 300 ms after the last key; label it "Assign exam"; show "Searching…", "No exams match" and "Search failed" as its messages.
Does your file pass T1–T10?
Question 6 of 8
p6-start.jsx is the reference with one line changed: pressing Escape while the list is open empties the box. It fails T6 alone.
Change that one line so that T6 passes.
Question 7 of 8
A home-goods shop's product search, with the same useDebounced(text, 300):
Shopper 1 types desk, and 8 products are listed. She presses Down three times, to "Desk lamp, brass", types lamp, and presses Enter as soon as the new list shows: the basket gets "Desk lamp, walnut". Shopper 2 types desk, then lamp, and before the new list shows she presses Down twice; when it shows, she presses Enter: the basket gets "Desk lamp, steel".
Which one change stops both wrong picks?
function ProductSearch({ search, onPick }) { const [text, setText] = useState(''); const query = useDebounced(text, 300); const [items, setItems] = useState([]); const [active, setActive] = useState(-1); useEffect(() => { if (!query) return; const ac = new AbortController(); search(query, { signal: ac.signal }).then((found) => { if (!ac.signal.aborted) setItems(found); }, () => {}); return () => ac.abort(); }, [query]); function onKeyDown(e) { if (e.key === 'ArrowDown') setActive((i) => Math.min(i + 1, items.length - 1)); if (e.key === 'ArrowUp') setActive((i) => Math.max(i - 1, 0)); if (e.key === 'Enter' && active >= 0) onPick(items[active]); } return ( <> <input role="combobox" aria-label="Search products" aria-expanded={items.length > 0} aria-controls="products" aria-activedescendant={active >= 0 ? `product-${active}` : undefined} value={text} onChange={(e) => setText(e.target.value)} onKeyDown={onKeyDown} /> <ul id="products" role="listbox" aria-label="Products"> {items.map((p, i) => <li key={p.id} id={`product-${i}`} role="option" aria-selected={i === active}>{p.title}</li>)} </ul> </> );}Question 8 of 8
A video service's film search, with the same useDebounced(text, 300):
A viewer searches zzq, and nothing matches. She presses Down, then Enter: the box fills with "No films match", and the player is asked to play it.
Which is the bug?
function FilmPicker({ search, onPick }) { const [text, setText] = useState(''); const query = useDebounced(text, 300); const [res, setRes] = useState({ status: 'idle', items: [] }); const [active, setActive] = useState(-1); useEffect(() => { if (!query) return; const ac = new AbortController(); search(query, { signal: ac.signal }).then((items) => { if (!ac.signal.aborted) { setRes({ status: items.length ? 'done' : 'empty', items }); setActive(-1); } }, () => {}); return () => ac.abort(); }, [query]); const rows = res.status === 'empty' ? [{ id: 'none', title: 'No films match' }] : res.items; function onKeyDown(e) { if (e.key === 'ArrowDown') setActive((i) => Math.min(i + 1, rows.length - 1)); if (e.key === 'Enter' && active >= 0) { setText(rows[active].title); onPick(rows[active]); } } return ( <> <input role="combobox" aria-label="Search films" aria-expanded={rows.length > 0} aria-controls="films" aria-activedescendant={active >= 0 ? `film-${active}` : undefined} value={text} onChange={(e) => setText(e.target.value)} onKeyDown={onKeyDown} /> <ul id="films" role="listbox" aria-label="Films"> {rows.map((r, i) => <li key={r.id} id={`film-${i}`} role="option" aria-selected={i === active}>{r.title}</li>)} </ul> </> );}Go deeper
Pro tip
- Write the race test first. Release two replies in reverse order, newest first and the stale one last, and assert after both have settled; it proves the one property users notice. When: before the component exists (step 11). Verify:
node --test build.test.mjs; T3 fails on the EXH-2870 pilot and passes on the reference. Status: RAN (Node 24.19.0, React 19.3.0, jsdom 30.1.1).
Source
Valid as of 2026-10-04.
- W3C WAI-ARIA Authoring Practices, Combobox Pattern: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/ (
apg-combobox.txt). Quoted: "When a descendant of a listbox, grid, or tree popup is focused, DOM focus remains on the combobox and the combobox has aria-activedescendant set to a value that refers to the focused element within the popup." Supports step 7. Also: "When the combobox popup is not visible, the element with role combobox has aria-expanded set to false." (step 5) and "Escape: Dismisses the popup if it is visible." (step 8). - W3C WAI-ARIA Authoring Practices, Editable Combobox With List Autocomplete Example: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/ (
apg-combobox-list.txt). Quoted: "Enables assistive technologies to know which element the application regards as focused while DOM focus remains on the input element." Supports steps 5 and 7. - Front End Interview Handbook, User Interface Interview Questions: https://www.frontendinterviewhandbook.com/coding/build-front-end-user-interfaces (
feih-build-ui.txt). Quoted: "Does the component deal with race conditions in network/async requests? E.g. a new network request is fired before the response for the previous request is returned." and "What if you need to have multiple instances of this component on the page?" Supports steps 3 and 6, and "What if there are no items, what do you show? Hint: show an empty state to indicate absence of contents. Do not show nothing." supports step 4. - Testing Library, user-event Introduction: https://testing-library.com/docs/user-event/intro (
tl-user-event.txt). Quoted: "fireEvent dispatches DOM events, whereas user-event simulates full interactions, which may fire multiple events and do additional checks along the way." Supports step 11; this lesson's own harness types through the input's value setter and aninputevent, which is the narrowerfireEventroute. - react.dev, Synchronizing with Effects: https://react.dev/learn/synchronizing-with-effects (
react-effects.txt). Quoted: "If your Effect fetches something, the cleanup function should either abort the fetch or ignore its result" Supports step 2, linked toreact-effects-synchronization.
Related lessons
- Read first: Kata: debounce
- Read first: Kata: cancellable fetch with AbortController
- Read first: Focus and keyboard management
- Read first: Testing React components with Testing Library
- Related: Cancellation and out-of-order responses
- Related: Effects as synchronization
- Related: Semantic HTML, the accessibility tree and ARIA
- Related: Design prompt: a typeahead search box
- Related: Client-side server-state cache
Checked against the tools and docs as of 2026-10-04.