Skip to content

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?

Code for this question
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>}    </>  );}
Pick one

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

Two values. 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 query starts 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?

Code for this question
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} />;}
Pick one

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?

Code for this question
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>    </>  );}
Pick one

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?

Code for this question
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>    </>  );}
Pick one

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?

Code for this question
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>    </>  );}
Pick one

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?

Code for this question
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>    </>  );}
Pick one

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?

Code for this question
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>    </>  );}
Pick one

Go deeper

Pro tip
  1. 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 an input event, which is the narrower fireEvent route.
  • 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 to react-effects-synchronization.

Checked against the tools and docs as of 2026-10-04.