React - Autocomplete / Typeahead

Overview

Estimated time: 20–30 minutes

Implement a typeahead input with debounced filtering, arrow key navigation, and aria attributes.

Try it: Typeahead

View source
function useDebounced(value, ms){
  const [v, setV] = React.useState(value);
  React.useEffect(() => { const t = setTimeout(() => setV(value), ms); return () => clearTimeout(t); }, [value, ms]);
  return v;
}
function Typeahead(){
  const data = ['Apple','Apricot','Banana','Blackberry','Blueberry','Cherry','Grape','Grapefruit','Lemon','Lime','Mango','Orange','Peach','Pear','Pineapple','Plum','Raspberry','Strawberry','Watermelon'];
  const [q, setQ] = React.useState('');
  const dq = useDebounced(q, 120);
  const [active, setActive] = React.useState(-1);
  const results = dq? data.filter(x => x.toLowerCase().includes(dq.toLowerCase())) : [];
  const listId = 'ta-list';
  return (
    <div role="combobox" aria-expanded={results.length>0} aria-owns={listId} aria-haspopup="listbox">
      <input aria-autocomplete="list" aria-controls={listId} value={q} onChange={e => { setQ(e.target.value); setActive(-1);} } onKeyDown={e => {
        if (e.key==='ArrowDown') { setActive(a => Math.min(results.length-1, a+1)); e.preventDefault(); }
        if (e.key==='ArrowUp') { setActive(a => Math.max(0, a-1)); e.preventDefault(); }
        if (e.key==='Enter' && active>=0) { setQ(results[active]); setActive(-1); }
      }} placeholder="Search fruits" />
      {results.length > 0 && (
        <ul id={listId} role="listbox" style={{border:'1px solid var(--border)', marginTop:4, borderRadius:6, padding:4}}>
          {results.map((x,i) => (
            <li key={x} role="option" aria-selected={i===active} onMouseEnter={() => setActive(i)} onMouseDown={(e) => { e.preventDefault(); setQ(x); setActive(-1);} } style={{padding:'4px 6px', borderRadius:4, background: i===active? 'var(--accent-subtle)':'transparent'}}>
              {x}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
ReactDOM.createRoot(document.getElementById('try-typeahead')).render(<Typeahead />);

Syntax primer

  • Debounce input to avoid filtering on every keystroke.
  • Use WAI-ARIA combobox pattern roles and attributes.

Common pitfalls

  • Click selection blur—use onMouseDown to select without losing focus first.

Exercises

  1. Load suggestions from a network API and show a loading state.

Checks for Understanding

  1. Which WAI-ARIA roles/attributes are used for an autocomplete combobox pattern?
  2. Why debounce the input value before filtering?
  3. Why is onMouseDown used for selecting an option instead of onClick?
Answers
  1. Use role="combobox" with aria-haspopup="listbox", aria-owns/aria-controls pointing to the listbox, and list items with role="option" and aria-selected on the active item.
  2. To avoid running filter logic on every keystroke and to reduce flicker/overwork when users type quickly.
  3. onMouseDown fires before the input loses focus, allowing selection without the list closing due to blur.