A search input that suggests matches as the user types, with keyboard navigation and cancellation of stale requests.
Illustration coming soon
HOW TO USE THIS CHALLENGE
1. Read the briefClarify decisions before coding.
2. Build from memoryUse the 45-minute target.
3. Study the guideCompare architecture, tests, and trade-offs.
REQUIREMENTS
Debounce input so a request isn't fired on every keystroke.
Render a dropdown of matching suggestions below the input.
Support keyboard navigation: arrow keys move highlight, Enter selects, Escape closes.
Highlight the matched substring within each suggestion.
Clicking outside the dropdown closes it without changing the input.
Show empty and loading states distinctly.
EDGE CASES
Responses can arrive out of order — a slow request for an earlier query must not overwrite a newer result.
Empty query should clear suggestions rather than show all results.
Rapid backspacing shouldn't reopen a stale dropdown from an in-flight request.
No matches found should render a clear empty state, not a blank dropdown.
ACCESSIBILITY
Use a combobox pattern: input has role="combobox", aria-expanded, aria-controls pointing at the listbox.
Suggestions list has role="listbox"; each option role="option" with aria-selected reflecting the highlighted item.
Announce the active option via aria-activedescendant instead of moving real focus off the input.
SUGGESTED APPROACH
Keep query, suggestions, activeIndex, and a requestId (or AbortController) in state.
Debounce the query before firing the request; on each new request, increment/track a request token so a resolved-but-stale response is ignored.
Derive the dropdown's open/closed state from whether there are suggestions and whether the input is focused, not a separate boolean that can drift out of sync.
Handle keyboard events on the input itself (not the list) so focus never leaves the field.
EVALUATION RUBRIC
Correctly cancels/ignores stale responses when queries resolve out of order.
Keyboard flow (arrows, Enter, Escape) works without the browser scrolling the page.
Combobox ARIA relationships are wired correctly, not just visually styled to look like one.
Debounce delay is tuned so typing feels responsive, not laggy.
01Understand the product before coding
Learning goals
Debounce input so a request isn't fired on every keystroke.
Render a dropdown of matching suggestions below the input.
Support keyboard navigation: arrow keys move highlight, Enter selects, Escape closes.
Highlight the matched substring within each suggestion.
Decisions to state aloud
Responses can arrive out of order — a slow request for an earlier query must not overwrite a newer result.
Empty query should clear suggestions rather than show all results.
Rapid backspacing shouldn't reopen a stale dropdown from an in-flight request.
No matches found should render a clear empty state, not a blank dropdown.
02State model and invariants
Separate immediate input from the debounced query. Store the active option by stable id and make request status a discriminated union. Open state is derived from focus, query validity, and results; it is not another boolean that can contradict them.
01Autocomplete owns input, active id, request lifecycle, and selection.
02SuggestionList renders listbox semantics without duplicating selection state.
03SuggestionOption highlights text using React nodes rather than raw HTML.
04useDebouncedValue controls timing; one request effect owns AbortController cleanup.
05A hidden status region announces loading, count, empty, and error states.
04Reference implementation walkthrough
Step 1
Create a cancellable request pipeline
Normalize and debounce the input, abort the request owned by the previous query, and ignore AbortError. Debouncing reduces frequency; cancellation prevents obsolete work from committing.
Arrow keys update activeId, Enter commits, and Escape closes. aria-activedescendant makes the virtual focus perceivable without breaking normal text editing.
Use visible stable ids, deliberately choose wrap or clamp behavior, and clear the active id when new results no longer contain it. Native cursor movement keys remain untouched.
Step 4
Highlight and dismiss safely
Split labels at match boundaries and render mark plus text nodes. Close when focus leaves the whole widget, on Escape, or after selection; clean any document-level pointer listener.
SOLComplete solution, explained simply
Build it yourself first. This is one correct implementation, not the only one — read it top to bottom, then compare the shape of your version.
We are building a search box that shows a dropdown of matching suggestions as you type. The one big idea: keep two separate pieces of text. One updates on every keystroke so typing feels instant. The other is a delayed copy that waits until you pause, and only that one asks the server. Everything else — cancelling old requests, keyboard arrows, the highlight — hangs off those two values.
Autocomplete.tsx
1import{ useEffect, useId, useRef, useState }from"react";23type Suggestion={id: string; label: string };4type Results=5|{status:"idle"}6|{status:"loading"; items:Suggestion[]}7|{status:"success"; items:Suggestion[]}8|{status:"error"; message: string };910// Copies `value` across only after it stops changing for `ms` milliseconds.11function useDebounced<T>(value:T,ms: number){12const[debounced, setDebounced]=useState(value);13useEffect(()=>{14const timer =setTimeout(()=>setDebounced(value), ms);15return()=>clearTimeout(timer);16},[value, ms]);17return debounced;18}1920exportfunctionAutocomplete({21 search,22}:{23search:(query: string,signal:AbortSignal)=>Promise<Suggestion[]>;24}){25const[input, setInput]=useState("");// updates every keystroke26const[activeId, setActiveId]= useState<string |null>(null);27const[open, setOpen]=useState(false);28const[results, setResults]= useState<Results>({status:"idle"});29const query =useDebounced(input.trim(),250);// updates when you pause30const listId =useId();31const rootRef = useRef<HTMLDivElement>(null);3233// 1. Fetch when the paused query changes. Cancel the request before it.34useEffect(()=>{35if(query.length<2){36setResults({status:"idle"});37return;38}39const controller =newAbortController();40setResults((prev)=>({41status:"loading",42items:"items"in prev ? prev.items:[],43}));44search(query, controller.signal)45.then((items)=>setResults({status:"success", items }))46.catch((error)=>{47if(error.name!=="AbortError"){48setResults({status:"error",message:"Something went wrong. Try again."});49}50});51return()=> controller.abort();52},[query, search]);5354const items ="items"in results ? results.items:[];5556// 2. If the list refreshes and the highlighted item is gone, drop the highlight.57useEffect(()=>{58if(activeId &&!items.some((item)=> item.id=== activeId))setActiveId(null);59},[items, activeId]);6061// 3. Close the dropdown when focus leaves the whole widget.62useEffect(()=>{63const element = rootRef.current;64functiononFocusOut(event:FocusEvent){65if(!element?.contains(event.relatedTargetasNode))setOpen(false);66}67 element?.addEventListener("focusout", onFocusOut);68return()=> element?.removeEventListener("focusout", onFocusOut);69},[]);7071functionmove(delta: number){72if(items.length===0)return;73const index = items.findIndex((item)=> item.id=== activeId);74const next =(index + delta + items.length)% items.length;// wraps around75setActiveId(items[next].id);76}7778functionselect(suggestion:Suggestion){79setInput(suggestion.label);80setOpen(false);81setActiveId(null);82}8384functiononKeyDown(event:React.KeyboardEvent){85if(event.key==="ArrowDown"){ event.preventDefault();setOpen(true);move(1);}86elseif(event.key==="ArrowUp"){ event.preventDefault();setOpen(true);move(-1);}87elseif(event.key==="Enter"&& activeId){88 event.preventDefault();89const chosen = items.find((item)=> item.id=== activeId);90if(chosen)select(chosen);91}92elseif(event.key==="Escape")setOpen(false);93}9495const showList =96 open &&(results.status==="loading"|| items.length>0|| results.status==="error");9798return(99<div ref={rootRef} className="autocomplete">100<input101 role="combobox"102 aria-expanded={showList}103 aria-controls={listId}104 aria-autocomplete="list"105 aria-activedescendant={activeId ? listId +"-"+ activeId :undefined}106 value={input}107 onChange={(event)=>{setInput(event.target.value);setOpen(true);}}108 onFocus={()=>setOpen(true)}109 onKeyDown={onKeyDown}110/>111112{showList &&(113<ul id={listId} role="listbox">114{items.map((item)=>(115<li116 key={item.id}117 id={listId +"-"+ item.id}118 role="option"119 aria-selected={item.id=== activeId}120 onMouseDown={(event)=> event.preventDefault()}// keep the input focused121 onClick={()=>select(item)}122>123{highlight(item.label, query)}124</li>125))}126{results.status==="success"&& items.length===0&&<li>No matches</li>}127{results.status==="error"&&<li role="alert">{results.message}</li>}128</ul>129)}130131<p role="status" className="sr-only">132{results.status==="loading"?"Loading suggestions"133: results.status==="success"? items.length+" suggestions"134: results.status==="error"? results.message:""}135</p>136</div>137);138}139140// Slice the label around the typed text and wrap the match in a real <mark>.141// Never uses innerHTML, so a label from the server cannot inject anything.142functionhighlight(label: string,query: string){143if(!query)return label;144const at = label.toLowerCase().indexOf(query.toLowerCase());145if(at ===-1)return label;146return(147<>148{label.slice(0, at)}149<mark>{label.slice(at, at + query.length)}</mark>150{label.slice(at + query.length)}151</>152);153}
How each part works
Two texts: input and query
input changes on every keystroke, so what you see in the box is never behind your typing. query is a delayed copy made by useDebounced: it only catches up 250ms after you stop typing. The network request is tied to query, not input, so a fast typist causes one request at the end instead of one per letter.
results has a status label
Instead of separate isLoading, error, and items values that could disagree with each other, one object holds exactly one of: idle, loading, success, error. It is impossible to be 'loading and also showing an error' because the shape does not allow it.
Each new search cancels the previous one
AbortController is a stop button for a fetch. When query changes, React first runs the previous effect's cleanup — controller.abort() — and only then starts the new request. So a slow old response cannot land late and replace newer results. We ignore AbortError because that error is us cancelling on purpose, not a real failure.
The keyboard highlight is just an id
The text cursor never leaves the input, so you can keep typing and use Home or End normally. activeId remembers which option is highlighted, and aria-activedescendant tells a screen reader 'the user is on this option' without moving the browser's real focus. Arrow keys change activeId; Enter picks whatever it points at.
move wraps around the list
It finds the current option's position, adds +1 or -1, then uses the remainder operator so going past the last option loops back to the first, and going up from the first loops to the last. Adding items.length before the remainder keeps it working for negative numbers.
Effect 2 keeps the highlight honest
When results refresh, the previously highlighted option might not be in the new list. This effect clears activeId in that case, so the highlight never points at something that is not on screen.
Three ways to close
Escape closes it. Clicking an option selects it and closes. Moving focus out of the whole widget closes it — the focusout listener checks whether the new focus target is still inside rootRef. Each option calls preventDefault on mousedown so the click does not yank focus out of the input before onClick runs.
The hidden status line
The role="status" paragraph is visually hidden but read aloud by screen readers when it changes, announcing 'Loading suggestions', '5 suggestions', or the error message, so a non-sighted user knows what happened.
highlight builds elements, not HTML
It cuts the label into before-match, match, and after-match pieces and wraps the middle in a real <mark> element. Because it never touches innerHTML, a label coming from an untrusted server cannot smuggle in a script.
Why this is correct
An instant input plus a delayed query is what makes the box feel fast without sending a request per keystroke.
Debouncing only reduces how often you ask; cancelling the old request (and letting React run the cleanup first) is what actually stops a stale answer from winning.
A single status field makes impossible combinations like 'loading and errored' unrepresentable.
The highlight is a remembered id plus aria-activedescendant, so real keyboard focus stays in the input the entire time.
Rendering the match with a React <mark> element instead of innerHTML means server text can never become executable code.
05Testing strategy
Critical behavior
Correctly cancels/ignores stale responses when queries resolve out of order.
Keyboard flow (arrows, Enter, Escape) works without the browser scrolling the page.
Combobox ARIA relationships are wired correctly, not just visually styled to look like one.
Debounce delay is tuned so typing feels responsive, not laggy.
Failure and boundary cases
Responses can arrive out of order — a slow request for an earlier query must not overwrite a newer result.
Empty query should clear suggestions rather than show all results.
Rapid backspacing shouldn't reopen a stale dropdown from an in-flight request.
No matches found should render a clear empty state, not a blank dropdown.
Accessibility
Use a combobox pattern: input has role="combobox", aria-expanded, aria-controls pointing at the listbox.
Suggestions list has role="listbox"; each option role="option" with aria-selected reflecting the highlighted item.
Announce the active option via aria-activedescendant instead of moving real focus off the input.
06Performance and production hardening
Bound and expire any query cache.
Limit suggestion count instead of mounting hundreds of options.
Create locale collators once per locale.
Measure service latency separately from debounce delay.
QAInterview questions and model answers
Answer aloud first. Then open the model answer and compare state ownership, failure handling, accessibility, and trade-offs—not exact wording.
Model answer
Typing must update immediately while the query is delayed. Combining them either fetches every keystroke or makes the visible input lag.