Blog

Part 52 · Build an Autocomplete in React

Build a search box with suggestions, step by step, the way an interview asks for it. Old answers, debouncing, loading and error states, the keyboard, ARIA, highlighting and a cache.

This is the second “build it yourself” part. A machine coding interview gives you a small app to build, in a real editor, while someone watches. A common task is an autocomplete: a text box that shows suggestions under it as you type.

It looks small, but it uses many earlier parts. A controlled input from Part 28. Effects that clean up, from Part 12, and fetching from Part 13. The debounce hook from Part 17. ARIA and the keyboard, from Part 46 and Part 47.

We build it in seven steps, and each step is an app you can run. At the end, we look at what an interviewer checks, and what they ask next.

Try this first

Here is step 1: a box that filters a list of foods as you type. Read it, but don’t press Run yet.

import { useState } from 'react'

const FOODS = [
  'Apple', 'Banana', 'Bread', 'Butter', 'Carrot', 'Cheese', 'Cherry', 'Corn', 'Cream',
  'Grape', 'Green tea', 'Honey', 'Ice cream', 'Lemon', 'Mango', 'Melon', 'Noodles',
  'Onion', 'Orange', 'Pasta', 'Peach', 'Pear', 'Rice', 'Salt', 'Sugar', 'Tomato',
]

export default function App() {
  const [text, setText] = useState('')
  const query = text.trim().toLowerCase()
  const matches = query === '' ? [] : FOODS.filter(food => food.toLowerCase().includes(query))

  return (
    <div>
      <label>
        Food <input value={text} onChange={e => setText(e.target.value)} />
      </label>
      <ul>
        {matches.map(food => (
          <li key={food}>{food}</li>
        ))}
      </ul>
    </div>
  )
}

Guess: if you type re, how many foods show? Is “Green tea” one of them?

Press Run and type re.

Four foods show: Bread, Cream, Green tea and Ice cream. None of them starts with re. The code asks includes, so re can be anywhere in the name. “Green tea” has it in the middle of “Green”.

Is that what the user wants? Maybe, maybe not. That is a question to ask the interviewer, before you write any code.

The task, as an interviewer gives it

An interviewer often reads out something like this:

  1. Show a text box. As the user types, show matching suggestions under it.
  2. The suggestions come from a server. Our pretend server is slow, and its answers take a random time.
  3. Don’t send a request for every key press.
  4. Show when it is loading, when nothing matches, and when the server fails.
  5. Up and Down arrows move a highlight. Enter picks. Escape closes the list.
  6. Screen reader users must be able to use it.
  7. Show which part of each suggestion matches.
  8. Don’t ask the server twice for the same word.

Before you start, ask questions. Each answer changes the code:

  • Should re match the start of a word, or anywhere?
  • Is the case important? Should “RE” find “Bread”?
  • How many suggestions at most?
  • Must the user pick a suggestion, or can they type anything?
  • What should happen after a pick?

Here are our answers. Match anywhere, and ignore the case. Show at most 8. Allow any text, and put the picked food in the box.

Step 1: filter a list as you type

You saw step 1 in “Try this first”. The box is a controlled input, as in Part 28: the text lives in state, and onChange updates it.

The list of matches is not in state. It is worked out from text while rendering, so it always fits text. We don’t need an Effect or a second state for it. React’s docs say: “When something can be calculated from the existing props or state, don’t put it in state.”

trim() removes spaces at the ends, and toLowerCase() on both sides ignores the case. An empty box shows nothing, not all 26 foods.

Step 2: ask a pretend server

The playground can’t reach a real server, so searchFoods pretends to be one. Each answer takes a random time, from 100 to 1,500 milliseconds. The answer also says which word it is for, so we can see the bug.

The request goes in an Effect, because it keeps the page in step with something outside React. The Effect runs again whenever query changes.

When the box is empty, we show nothing. shown works that out while rendering, as in step 1. So the Effect only has to send requests.

import { useEffect, useState } from 'react'

const FOODS = [
  'Apple', 'Banana', 'Bread', 'Butter', 'Carrot', 'Cheese', 'Cherry', 'Corn', 'Cream',
  'Grape', 'Green tea', 'Honey', 'Ice cream', 'Lemon', 'Mango', 'Melon', 'Noodles',
  'Onion', 'Orange', 'Pasta', 'Peach', 'Pear', 'Rice', 'Salt', 'Sugar', 'Tomato',
]

type Answer = { query: string; foods: string[] }

// A pretend server. Each answer takes a random time: 100 to 1,500 ms.
function searchFoods(query: string): Promise<Answer> {
  const delay = 100 + Math.random() * 1400
  return new Promise(resolve => {
    setTimeout(() => {
      const q = query.toLowerCase()
      resolve({ query, foods: FOODS.filter(food => food.toLowerCase().includes(q)) })
    }, delay)
  })
}

export default function App() {
  const [text, setText] = useState('')
  const [answer, setAnswer] = useState<Answer | null>(null)
  const query = text.trim()

  useEffect(() => {
    if (query === '') return
    searchFoods(query).then(result => setAnswer(result))
  }, [query])

  const shown = query === '' ? null : answer

  return (
    <div>
      <label>
        Food <input value={text} onChange={e => setText(e.target.value)} />
      </label>
      <p>Results for: {shown ? shown.query : 'nothing yet'}</p>
      <ul>
        {shown?.foods.map(food => (
          <li key={food}>{food}</li>
        ))}
      </ul>
    </div>
  )
}

Press Run. Type rea quickly, then wait two seconds. Look at “Results for”. Do it a few times: select the text, delete it, and type rea again.

Sometimes it says rea. Sometimes it says r or re, and the list shows foods that don’t match rea at all.

What went wrong

Each letter changed query, so three requests went out: for r, re and rea. Each answer called setAnswer when it came back. The answer that arrives last wins, and that isn’t always the newest request.

This is the race condition from Part 12. The requests race, and the page shows the one that finishes last.

Here is one run, with the times fixed so it is the same every time. You type one key every 150 ms. The server takes 1,200 ms for r, 300 ms for re and 500 ms for rea.

Time What happens The page shows results for
0 ms You type r. Request r goes out. nothing yet
150 ms You type “e”. Request re goes out. nothing yet
300 ms You type “a”. Request rea goes out. nothing yet
450 ms The answer for re arrives. re
800 ms The answer for rea arrives. rea
1,200 ms The answer for r arrives, last. r

The box says rea. The list is for r. We ran this exact sequence in our test, and the page ended on r.

Then we let the delays be random, as in the app above. We typed rea 200 times, with 150 ms between keys. The page ended on the wrong word in 109 of the 200 runs: 79 times on re, and 30 times on r.

An everyday example

You send three letters to a shop, one each day. Each one asks a new question. Letters don’t always arrive in the order you sent them. If you act on the reply that comes last, you may act on your oldest question.

The exact version

The order of the answers depends on the server and the network, not on React. So the fix is in our code: an answer must check that it is still wanted.

Fix: ignore old answers

This is Fix 1 from Part 12. Each run of the Effect has its own ignore flag. When query changes, React runs the cleanup of the old Effect first. The cleanup sets that run’s flag to true. So when the old answer arrives, it is ignored.

import { useEffect, useState } from 'react'

const FOODS = [
  'Apple', 'Banana', 'Bread', 'Butter', 'Carrot', 'Cheese', 'Cherry', 'Corn', 'Cream',
  'Grape', 'Green tea', 'Honey', 'Ice cream', 'Lemon', 'Mango', 'Melon', 'Noodles',
  'Onion', 'Orange', 'Pasta', 'Peach', 'Pear', 'Rice', 'Salt', 'Sugar', 'Tomato',
]

type Answer = { query: string; foods: string[] }

// A pretend server. Each answer takes a random time: 100 to 1,500 ms.
function searchFoods(query: string): Promise<Answer> {
  const delay = 100 + Math.random() * 1400
  return new Promise(resolve => {
    setTimeout(() => {
      const q = query.toLowerCase()
      resolve({ query, foods: FOODS.filter(food => food.toLowerCase().includes(q)) })
    }, delay)
  })
}

export default function App() {
  const [text, setText] = useState('')
  const [answer, setAnswer] = useState<Answer | null>(null)
  const query = text.trim()

  useEffect(() => {
    if (query === '') return
    let ignore = false
    searchFoods(query).then(result => {
      if (!ignore) setAnswer(result)
    })
    return () => {
      ignore = true
    }
  }, [query])

  const shown = query === '' ? null : answer

  return (
    <div>
      <label>
        Food <input value={text} onChange={e => setText(e.target.value)} />
      </label>
      <p>Results for: {shown ? shown.query : 'nothing yet'}</p>
      <ul>
        {shown?.foods.map(food => (
          <li key={food}>{food}</li>
        ))}
      </ul>
    </div>
  )
}

Run it and type rea quickly, as many times as you like. “Results for” always ends on rea. In our test, so did the fixed run above, and all 200 random runs.

Now clear the box, then type r. Until the answer for r arrives, “Results for” still says rea. The old answer stays until a new one replaces it. Step 4 keeps the old list on purpose.

React’s docs use this same example, a search box, to explain the bug. Their fix is this same flag: “To fix the race condition, you need to add a cleanup function to ignore stale responses”. Stale means old, and no longer true.

Or stop old requests with AbortController

The flag doesn’t stop the old requests. Their answers still arrive, and are ignored.

Part 12’s Fix 2 really stops them, with AbortController. The real fetch takes a signal, as fetch(url, { signal }). Here is the same Effect inside a small hook. It assumes a searchFoods that takes a signal, the way fetch does:

import { useEffect, useState } from 'react'

type Answer = { query: string; foods: string[] }
declare function searchFoods(query: string, signal: AbortSignal): Promise<Answer>

function useAnswer(query: string) {
  const [answer, setAnswer] = useState<Answer | null>(null)
  useEffect(() => {
    if (query === '') return
    const controller = new AbortController()
    searchFoods(query, controller.signal)
      .then(result => setAnswer(result))
      .catch(error => {
        if (error.name !== 'AbortError') throw error
      })
    return () => controller.abort()
  }, [query])
  return query === '' ? null : answer
}

We tried it with a pretend server that stops when it gets the signal, like Part 12’s. We typed rea 150 ms apart. The requests for r and re were stopped, and only the answer for rea arrived. The page ended on rea.

Many apps use both ideas. In this part we keep the ignore flag, because it works with any promise.

Step 3: wait until the user stops typing

Our app still sends a request for every key. Typing cream sends five. Most of them are wasted, because the user hasn’t finished.

The fix is debouncing, from Part 17: wait until the text has stayed the same for a while. We copy Part 17’s useDebouncedValue hook. The request now waits for query, the debounced text, not text.

The pretend server also writes a line to the Console for every request, so we can count them.

import { useEffect, useState } from 'react'

const FOODS = [
  'Apple', 'Banana', 'Bread', 'Butter', 'Carrot', 'Cheese', 'Cherry', 'Corn', 'Cream',
  'Grape', 'Green tea', 'Honey', 'Ice cream', 'Lemon', 'Mango', 'Melon', 'Noodles',
  'Onion', 'Orange', 'Pasta', 'Peach', 'Pear', 'Rice', 'Salt', 'Sugar', 'Tomato',
]

type Answer = { query: string; foods: string[] }

// A pretend server. Each answer takes a random time: 100 to 1,500 ms.
function searchFoods(query: string): Promise<Answer> {
  console.log('request ' + query)
  const delay = 100 + Math.random() * 1400
  return new Promise(resolve => {
    setTimeout(() => {
      const q = query.toLowerCase()
      resolve({ query, foods: FOODS.filter(food => food.toLowerCase().includes(q)) })
    }, delay)
  })
}

function useDebouncedValue(value: string, delay: number) {
  const [debounced, setDebounced] = useState(value)
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay)
    return () => clearTimeout(id)
  }, [value, delay])
  return debounced
}

export default function App() {
  const [text, setText] = useState('')
  const [answer, setAnswer] = useState<Answer | null>(null)
  const query = useDebouncedValue(text.trim(), 300)

  useEffect(() => {
    if (query === '') return
    let ignore = false
    searchFoods(query).then(result => {
      if (!ignore) setAnswer(result)
    })
    return () => {
      ignore = true
    }
  }, [query])

  const shown = query === '' ? null : answer

  return (
    <div>
      <label>
        Food <input value={text} onChange={e => setText(e.target.value)} />
      </label>
      <p>Results for: {shown ? shown.query : 'nothing yet'}</p>
      <ul>
        {shown?.foods.map(food => (
          <li key={food}>{food}</li>
        ))}
      </ul>
    </div>
  )
}
request rea

Run it and type rea quickly. The Console shows one line: request rea.

There are no doubles from Strict Mode here. In development, Strict Mode runs each Effect’s setup, cleanup and setup again when the component first appears. But then the box is empty, so no request goes out. After that, an Effect runs once each time query changes.

We typed words one key at a time and counted the requests. A “key gap” is the time between two keys.

Typed Key gap Debounce Requests
cream 150 ms none 5
cream 150 ms 300 ms 1
cream 400 ms 300 ms 5

The last row matters. If the user stops for longer than the delay, a request goes out. On an exact test clock, keys 299 ms apart gave 1 request, and keys 301 ms apart gave 5. Then the old answer and the new one can still race. That is why we keep the ignore flag. Debouncing makes fewer requests. It doesn’t put them in order.

Here is that case, slowed down. You type re, stop for a moment, then type “a”.

1. You type r, then e. Each key stops the old timer and starts a new one. 2. No key for 300 ms: the timer ends. Request "re" goes out. 3. You type a. At 800 ms the "re" Effect is cleaned up, and "rea" goes out. 4. At 1,000 ms the answer for "rea" arrives. The page shows it. 5. At 1,400 ms the old answer for "re" arrives. Its flag is true: ignored. keys 300 ms timer requests the page 0 ms 500 ms 1,000 ms 1,500 ms r e a stopped ends ends "re": 1,000 ms "rea" ignore = true ignored Results for: nothing yet

Typing “rea” with a pause: the debounce, the requests, and a late answer. Press play, or step through it.

Here are the same steps in words. We ran them on an exact test clock, with the server’s delays fixed, so it is the same each time.

  1. At 0 ms you type r, and at 100 ms “e”. Each key starts a new 300 ms timer, and the cleanup stops the old one.
  2. At 400 ms, nothing has been typed for 300 ms. The timer ends, query becomes re, and request re goes out. The server will take 1,000 ms.
  3. At 500 ms you type “a”. At 800 ms, its timer ends. React cleans up the re Effect, so its ignore becomes true. Request rea goes out. It will take 200 ms.
  4. At 1,000 ms the answer for rea arrives. The page shows it.
  5. At 1,400 ms the answer for re arrives, late. Its flag is true, so it changes nothing.

useDeferredValue is not a debounce

React has a hook, useDeferredValue, that also makes a value change a little later. You may hear it named as a fix for search boxes. It solves a different problem: slow rendering, like a huge list that takes long to draw.

React’s docs say it plainly: “useDeferredValue does not by itself prevent extra network requests.”

When the work is not part of rendering, the docs say debouncing is “still useful”. It can “let you fire fewer network requests”.

Our requests aren’t part of rendering, so we debounce.

Step 4: loading, empty and error states

A user who sees nothing can’t tell why. Is it loading? Did nothing match? Did the server fail? Each case needs its own message.

Part 13 described the states a request can be in. We give them one variable with four values: 'idle', 'loading', 'done' and 'error'. One variable can’t say “loading” and “error” at the same time. Part 26 showed why that helps.

We also move the search into a custom hook, useFoodSearch, as in Part 16. The component reads like a list of what it uses. For an empty query, the hook gives back 'idle' and no foods. It works that out while rendering, like shown in step 2.

To let you see the error, the pretend server fails when you search for oops. It also sends at most 8 foods.

import { useEffect, useState } from 'react'

const FOODS = [
  'Apple', 'Banana', 'Bread', 'Butter', 'Carrot', 'Cheese', 'Cherry', 'Corn', 'Cream',
  'Grape', 'Green tea', 'Honey', 'Ice cream', 'Lemon', 'Mango', 'Melon', 'Noodles',
  'Onion', 'Orange', 'Pasta', 'Peach', 'Pear', 'Rice', 'Salt', 'Sugar', 'Tomato',
]

// A pretend server. Each answer takes 100 to 1,500 ms. It sends at most 8 foods.
// It fails for the word "oops", so you can see the error state.
function searchFoods(query: string): Promise<string[]> {
  console.log('request ' + query)
  const delay = 100 + Math.random() * 1400
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const q = query.toLowerCase()
      if (q === 'oops') reject(new Error('The server is down.'))
      else resolve(FOODS.filter(food => food.toLowerCase().includes(q)).slice(0, 8))
    }, delay)
  })
}

function useDebouncedValue(value: string, delay: number) {
  const [debounced, setDebounced] = useState(value)
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay)
    return () => clearTimeout(id)
  }, [value, delay])
  return debounced
}

type Status = 'idle' | 'loading' | 'done' | 'error'

function useFoodSearch(query: string): { status: Status; foods: string[] } {
  const [status, setStatus] = useState<Status>('idle')
  const [foods, setFoods] = useState<string[]>([])

  useEffect(() => {
    if (query === '') return
    let ignore = false
    setStatus('loading')
    searchFoods(query).then(
      result => {
        if (ignore) return
        setFoods(result)
        setStatus('done')
      },
      () => {
        if (ignore) return
        setFoods([])
        setStatus('error')
      }
    )
    return () => {
      ignore = true
    }
  }, [query])

  if (query === '') return { status: 'idle', foods: [] }
  return { status, foods }
}

export default function App() {
  const [text, setText] = useState('')
  const query = useDebouncedValue(text.trim(), 300)
  const { status, foods } = useFoodSearch(query)

  let message = ''
  if (status === 'loading') message = 'Searching...'
  else if (status === 'error') message = 'Something went wrong. Please try again.'
  else if (status === 'done' && foods.length === 0) message = `No foods match "${query}".`
  else if (status === 'done') message = `${foods.length} found.`

  return (
    <div>
      <label>
        Food <input value={text} onChange={e => setText(e.target.value)} />
      </label>
      <p role="status">{message}</p>
      <ul>
        {foods.map(food => (
          <li key={food}>{food}</li>
        ))}
      </ul>
    </div>
  )
}
request xyz
request oops
request rea

Run it. Type rea, then try xyz and oops.

The first time you type, the message stays empty for 300 ms, while the debounce waits. Then it says “Searching…” until the answer comes. For rea it then says “3 found.” For xyz it says No foods match "xyz". For oops it says the server failed. Type e: 17 foods have an “e”, but the list shows 8.

While a new search runs, the old list stays on the page, so it doesn’t flash empty on every key. Some apps grey it out instead.

The message is in a <p role="status">, a live region from Part 46. A screen reader reads its new text out, without moving the focus. Part 46 said the region must be on the page before the message. So the <p> is always there, and only its text changes.

Step 5: the keyboard and ARIA

Now the list must work without a mouse. The W3C’s ARIA Authoring Practices Guide has a pattern for exactly this. It calls the box a combobox: “an input widget that has an associated popup”. A widget is a control on the page, like a button or a box. A popup is the part that opens, here the list.

What the W3C pattern asks for

Our box is the guide’s “list autocomplete with manual selection”. It shows suggestions. Nothing is picked until the user picks it. Here is what the guide asks for, with its own words in quotes:

  • The input has role="combobox".
  • aria-expanded is true when the list is showing and false when it isn’t.
  • aria-controls points at the list’s id. The guide says it “only needs to be set when the popup is visible”.
  • The list has role="listbox", and each suggestion has role="option".
  • The focus stays in the box. The DOM is the browser’s tree of tags on the page, and “DOM focus remains on the combobox”. aria-activedescendant names the highlighted option’s id.
  • The highlighted option has aria-selected="true".
  • aria-autocomplete="list" says what kind of box it is.

And for the keys:

  • Down Arrow “moves focus into the popup”. Up Arrow does the same, from the last option.
  • On an option, Enter “Accepts the focused option in the listbox”. It closes the list and puts the value in the box.
  • Escape closes the list if it is open. If the list is already closed, Escape may clear the box.

What about Down on the last option? The pattern says it either goes back to the box or does nothing. The guide’s own example goes to the first option instead. Up on the first goes to the last. The example says this “is useful when Home and End move the editing cursor”. We copy the example. Part 47’s listbox stopped at the ends.

This is the aria-activedescendant way from Part 47. Part 47 said it fits when the focus must stay in one place. Here it must: the user is still typing in the box. If the focus moved onto an option, the next letter would go nowhere.

The code

The search part is the same as step 4. New in this step: the useClickOutside hook from Part 17, the ARIA attributes, and handleKeyDown.

import { useEffect, useEffectEvent, useId, useRef, useState, type KeyboardEvent, type RefObject } from 'react'

const FOODS = [
  'Apple', 'Banana', 'Bread', 'Butter', 'Carrot', 'Cheese', 'Cherry', 'Corn', 'Cream',
  'Grape', 'Green tea', 'Honey', 'Ice cream', 'Lemon', 'Mango', 'Melon', 'Noodles',
  'Onion', 'Orange', 'Pasta', 'Peach', 'Pear', 'Rice', 'Salt', 'Sugar', 'Tomato',
]

// A pretend server. Each answer takes 100 to 1,500 ms. It sends at most 8 foods.
// It fails for the word "oops", so you can see the error state.
function searchFoods(query: string): Promise<string[]> {
  console.log('request ' + query)
  const delay = 100 + Math.random() * 1400
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const q = query.toLowerCase()
      if (q === 'oops') reject(new Error('The server is down.'))
      else resolve(FOODS.filter(food => food.toLowerCase().includes(q)).slice(0, 8))
    }, delay)
  })
}

function useDebouncedValue(value: string, delay: number) {
  const [debounced, setDebounced] = useState(value)
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay)
    return () => clearTimeout(id)
  }, [value, delay])
  return debounced
}

type Status = 'idle' | 'loading' | 'done' | 'error'

function useFoodSearch(query: string): { status: Status; foods: string[] } {
  const [status, setStatus] = useState<Status>('idle')
  const [foods, setFoods] = useState<string[]>([])

  useEffect(() => {
    if (query === '') return
    let ignore = false
    setStatus('loading')
    searchFoods(query).then(
      result => {
        if (ignore) return
        setFoods(result)
        setStatus('done')
      },
      () => {
        if (ignore) return
        setFoods([])
        setStatus('error')
      }
    )
    return () => {
      ignore = true
    }
  }, [query])

  if (query === '') return { status: 'idle', foods: [] }
  return { status, foods }
}

function useClickOutside(ref: RefObject<HTMLElement | null>, onOutside: () => void) {
  const onPressOutside = useEffectEvent(onOutside)
  useEffect(() => {
    function handlePointerDown(e: PointerEvent) {
      const box = ref.current
      if (box && !box.contains(e.target as Node)) onPressOutside()
    }
    document.addEventListener('pointerdown', handlePointerDown)
    return () => document.removeEventListener('pointerdown', handlePointerDown)
  }, [ref])
}

export default function App() {
  const id = useId()
  const listId = id + '-list'
  const boxRef = useRef<HTMLDivElement>(null)
  const [text, setText] = useState('')
  const [open, setOpen] = useState(false)
  const [activeFood, setActiveFood] = useState<string | null>(null)
  const query = useDebouncedValue(text.trim(), 300)
  const { status, foods } = useFoodSearch(query)
  useClickOutside(boxRef, () => setOpen(false))

  const showList = open && foods.length > 0
  const active = activeFood === null ? -1 : foods.indexOf(activeFood)

  function pick(food: string) {
    setText(food)
    setOpen(false)
    setActiveFood(null)
  }

  function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
    if (e.nativeEvent.isComposing || e.keyCode === 229) return
    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
      if (foods.length === 0) return
      e.preventDefault()
      const last = foods.length - 1
      const from = showList ? active : -1
      let next: number
      if (e.key === 'ArrowDown') next = from === last ? 0 : from + 1
      else next = from <= 0 ? last : from - 1
      setOpen(true)
      setActiveFood(foods[next])
    } else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
      setActiveFood(null)
    } else if (e.key === 'Enter') {
      if (showList && active !== -1) {
        e.preventDefault()
        pick(foods[active])
      }
    } else if (e.key === 'Escape') {
      if (open) {
        e.stopPropagation()
        setOpen(false)
      } else {
        setText('')
      }
      setActiveFood(null)
    } else if (e.key === 'Tab') {
      setOpen(false)
    }
  }

  let message = ''
  if (!open) message = ''
  else if (status === 'loading') message = 'Searching...'
  else if (status === 'error') message = 'Something went wrong. Please try again.'
  else if (status === 'done' && foods.length === 0) message = `No foods match "${query}".`
  else if (status === 'done') message = `${foods.length} found.`

  return (
    <div ref={boxRef} style={{ width: 240 }}>
      <label htmlFor={id}>Food</label>{' '}
      <input
        id={id}
        role="combobox"
        aria-autocomplete="list"
        aria-expanded={showList}
        aria-controls={showList ? listId : undefined}
        aria-activedescendant={showList && active !== -1 ? `${listId}-${active}` : undefined}
        value={text}
        onChange={e => {
          setText(e.target.value)
          setOpen(true)
          setActiveFood(null)
        }}
        onKeyDown={handleKeyDown}
      />
      {showList && (
        <ul
          id={listId}
          role="listbox"
          aria-label="Foods"
          onMouseDown={e => e.preventDefault()}
          style={{ listStyle: 'none', margin: 0, padding: 0, border: '1px solid gray' }}
        >
          {foods.map((food, i) => (
            <li
              key={food}
              id={`${listId}-${i}`}
              role="option"
              aria-selected={i === active}
              onClick={() => pick(food)}
              style={{ padding: 4, cursor: 'pointer', background: i === active ? 'lightblue' : undefined }}
            >
              {food}
            </li>
          ))}
        </ul>
      )}
      <p role="status">{message}</p>
    </div>
  )
}

Press Run. Click the box, type rea, and wait for the list. Press Down twice, then Enter. “Cream” is now in the box, and the list is closed.

Now try the other keys. Up from the box goes to the last option. Down on the last option goes back to the first. Left or Right takes the highlight away, and moves the cursor as usual. Escape closes the list. Press Escape again, and the box is emptied.

How it works

activeFood is the highlighted food, or null for none. We keep the food, not its number in the list. When a new answer arrives, the list changes. A saved number would then point at a different food. foods.indexOf(activeFood) finds where it is now, or gives -1 if it’s gone.

Each option’s id is the list’s id plus its number: ${listId}-${i}. aria-activedescendant names the same id. The ids come from useId, as in Part 47.

In handleKeyDown:

  • isComposing is true while an IME is still building a word, as Part 47 showed. Then Enter belongs to the IME, so we do nothing. We also check keyCode === 229, as Part 47 did, because MDN says isComposing can be false on the first or last key.
  • Down and Up call e.preventDefault(). In a text box, they would move the text cursor, the line that shows where the next letter goes. We removed that line and tested: Up moved the cursor to the start, and Down to the end, in all three browsers.
  • Enter calls e.preventDefault() only when it picks. In a <form>, Enter would also send the form.
  • Left and Right take the highlight away and do nothing else. The W3C example does the same: the cursor moves, and the focus is back in the box.
  • Escape closes the list whenever it is open, even with only a message. Then it calls e.stopPropagation(), so a modal around the box, like Part 47’s, stays open. In our test, a keydown listener on document didn’t hear that Escape.
  • Escape and Tab don’t call preventDefault. Tab must still move the focus out. Closing the list on Tab means it doesn’t stay open after the focus has gone.

useClickOutside closes the list when you press anywhere outside the <div>. An option is inside, so pressing it doesn’t close the list first. Its onClick then picks it.

onMouseDown={e => e.preventDefault()} on the list keeps the focus in the box. Without it, pressing an option takes the focus away from the box. The user would have to click the box again to keep typing.

What we measured in real browsers

We ran this app in Chromium, Firefox and WebKit, with real key presses and mouse clicks. In all three:

  • After typing rea and pressing Down twice, the focus was still on the <input>. aria-activedescendant named the “Cream” option.
  • Enter put “Cream” in the box and closed the list.
  • A click on “Ice cream” picked it, and the focus stayed on the box. Without onMouseDown on the list, the focus left the box.
  • A click outside closed the list. So did Tab.
  • The text cursor stayed at the end of rea while we pressed Down and Up.

We also read Chromium’s accessibility tree, the page as a screen reader gets it (Part 46). After two Down presses, the box was a combobox named “Food”, with expanded=true and autocomplete=list. Its activedescendant was the “Cream” option, and it controls the listbox named “Foods”. The list had three options. Only “Cream” had selected=true.

We can’t check what a screen reader says out loud. To be sure, test with a real screen reader, as Part 46 said.

What the browser doesn’t do for you

The browser doesn’t move the real focus, so it does none of the work that comes with it. You draw the highlight. If the list scrolls, you must scroll the option into view yourself. The W3C example “scrolls the option referenced by aria-activedescendant into view”. Our list has at most 8 short items, so it never scrolls. And aria-activedescendant works only on some roles, as Part 47 said. MDN lists combobox as one of them.

Step 6: highlight the matching letters

Requirement 7 asks us to show which part of each suggestion matches. HTML has a tag for this, <mark>. MDN says it shows text that is “marked or highlighted for reference or notation purposes”.

The safe way is to cut the name into three pieces: before the match, the match, and after it. Then put each piece in JSX as text. Here is the idea on its own:

import { useState } from 'react'

const FOODS = ['Bread', 'Cream', 'Green tea', 'Ice cream']

function Highlight({ text, query }: { text: string; query: string }) {
  const start = query === '' ? -1 : text.toLowerCase().indexOf(query.toLowerCase())
  if (start === -1) return text
  const end = start + query.length
  return (
    <>
      {text.slice(0, start)}
      <mark>{text.slice(start, end)}</mark>
      {text.slice(end)}
    </>
  )
}

export default function App() {
  const [text, setText] = useState('re')
  return (
    <div>
      <label>
        Food <input value={text} onChange={e => setText(e.target.value)} />
      </label>
      <ul>
        {FOODS.map(food => (
          <li key={food}>
            <Highlight text={food} query={text.trim()} />
          </li>
        ))}
      </ul>
    </div>
  )
}

Run it. Clear the box, then type CREAM in capitals. “Cream” and “Ice cream” are marked. The marked part keeps the food’s own capitals, because we slice the food’s name, not the query.

indexOf finds where the query starts in the name, or gives -1. We search the lower-case copies, so the case doesn’t matter. Then we slice the original name with the same numbers.

Why not build a string of HTML, like 'B<mark>re</mark>ad', and give it to dangerouslySetInnerHTML? Because then any < in a food name becomes a real tag. A food name comes from a server. A bad one could carry a tag that runs a script. React puts text in JSX on the page as text, so < stays a <. Common mistakes shows it.

Where this breaks

This marks only the first match. “Ice cream” has “c” twice, and only the first “c” is marked. If you want every match, loop with indexOf(query, from).

There’s a less common problem. For a few letters, toLowerCase() changes the length. We checked: 'İ'.toLowerCase().length is 2, not 1. With such a name, the numbers from the lower-case copy don’t fit the original name. We tried the name İstanbul kebab with the query stan. The mark landed on tanb, one letter late. Our food list has no such letters. A real app with names in many languages needs a careful matcher.

Step 7: remember answers

Type rea, then press Backspace, the key that deletes the last letter, to get re again. Our app asks the server for re again, even though it asked a moment ago. Requirement 8 says not to.

Caching means keeping answers to use again (Part 13). We keep them in a Map, a JavaScript object that stores values by key. The key is the lower-case query, so Rea and rea share one answer.

Where should the Map live? Not in state: changing it doesn’t need to change the page. We use a ref. It belongs to this one component, and goes away with it. Each test starts with an empty one. A Map outside the component would also work, and two boxes could share it. But it would last until the page reloads, and every test would share it, as Part 14 showed.

This is the finished app. It adds the cache to useFoodSearch, and Highlight inside each option.

import { useEffect, useEffectEvent, useId, useRef, useState, type KeyboardEvent, type RefObject } from 'react'

const FOODS = [
  'Apple', 'Banana', 'Bread', 'Butter', 'Carrot', 'Cheese', 'Cherry', 'Corn', 'Cream',
  'Grape', 'Green tea', 'Honey', 'Ice cream', 'Lemon', 'Mango', 'Melon', 'Noodles',
  'Onion', 'Orange', 'Pasta', 'Peach', 'Pear', 'Rice', 'Salt', 'Sugar', 'Tomato',
]

// A pretend server. Each answer takes 100 to 1,500 ms. It sends at most 8 foods.
// It fails for the word "oops", so you can see the error state.
function searchFoods(query: string): Promise<string[]> {
  console.log('request ' + query)
  const delay = 100 + Math.random() * 1400
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const q = query.toLowerCase()
      if (q === 'oops') reject(new Error('The server is down.'))
      else resolve(FOODS.filter(food => food.toLowerCase().includes(q)).slice(0, 8))
    }, delay)
  })
}

function useDebouncedValue(value: string, delay: number) {
  const [debounced, setDebounced] = useState(value)
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay)
    return () => clearTimeout(id)
  }, [value, delay])
  return debounced
}

type Status = 'idle' | 'loading' | 'done' | 'error'

function useFoodSearch(query: string): { status: Status; foods: string[]; foodsFor: string } {
  const [status, setStatus] = useState<Status>('idle')
  const [foods, setFoods] = useState<string[]>([])
  const [foodsFor, setFoodsFor] = useState('')
  const cache = useRef(new Map<string, string[]>())

  useEffect(() => {
    const key = query.toLowerCase()
    if (key === '') return
    const saved = cache.current.get(key)
    if (saved) {
      console.log('cache hit ' + key)
      setFoods(saved)
      setFoodsFor(key)
      setStatus('done')
      return
    }
    let ignore = false
    setStatus('loading')
    searchFoods(key).then(
      result => {
        cache.current.set(key, result)
        if (ignore) return
        setFoods(result)
        setFoodsFor(key)
        setStatus('done')
      },
      () => {
        if (ignore) return
        setFoods([])
        setStatus('error')
      }
    )
    return () => {
      ignore = true
    }
  }, [query])

  if (query === '') return { status: 'idle', foods: [], foodsFor: '' }
  return { status, foods, foodsFor }
}

function useClickOutside(ref: RefObject<HTMLElement | null>, onOutside: () => void) {
  const onPressOutside = useEffectEvent(onOutside)
  useEffect(() => {
    function handlePointerDown(e: PointerEvent) {
      const box = ref.current
      if (box && !box.contains(e.target as Node)) onPressOutside()
    }
    document.addEventListener('pointerdown', handlePointerDown)
    return () => document.removeEventListener('pointerdown', handlePointerDown)
  }, [ref])
}

function Highlight({ text, query }: { text: string; query: string }) {
  const start = query === '' ? -1 : text.toLowerCase().indexOf(query.toLowerCase())
  if (start === -1) return text
  const end = start + query.length
  return (
    <>
      {text.slice(0, start)}
      <mark>{text.slice(start, end)}</mark>
      {text.slice(end)}
    </>
  )
}

export default function App() {
  const id = useId()
  const listId = id + '-list'
  const boxRef = useRef<HTMLDivElement>(null)
  const [text, setText] = useState('')
  const [open, setOpen] = useState(false)
  const [activeFood, setActiveFood] = useState<string | null>(null)
  const query = useDebouncedValue(text.trim(), 300)
  const { status, foods, foodsFor } = useFoodSearch(query)
  useClickOutside(boxRef, () => setOpen(false))

  const showList = open && foods.length > 0
  const active = activeFood === null ? -1 : foods.indexOf(activeFood)

  function pick(food: string) {
    setText(food)
    setOpen(false)
    setActiveFood(null)
  }

  function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
    if (e.nativeEvent.isComposing || e.keyCode === 229) return
    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
      if (foods.length === 0) return
      e.preventDefault()
      const last = foods.length - 1
      const from = showList ? active : -1
      let next: number
      if (e.key === 'ArrowDown') next = from === last ? 0 : from + 1
      else next = from <= 0 ? last : from - 1
      setOpen(true)
      setActiveFood(foods[next])
    } else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
      setActiveFood(null)
    } else if (e.key === 'Enter') {
      if (showList && active !== -1) {
        e.preventDefault()
        pick(foods[active])
      }
    } else if (e.key === 'Escape') {
      if (open) {
        e.stopPropagation()
        setOpen(false)
      } else {
        setText('')
      }
      setActiveFood(null)
    } else if (e.key === 'Tab') {
      setOpen(false)
    }
  }

  let message = ''
  if (!open) message = ''
  else if (status === 'loading') message = 'Searching...'
  else if (status === 'error') message = 'Something went wrong. Please try again.'
  else if (status === 'done' && foods.length === 0) message = `No foods match "${query}".`
  else if (status === 'done') message = `${foods.length} found.`

  return (
    <div ref={boxRef} style={{ width: 240 }}>
      <label htmlFor={id}>Food</label>{' '}
      <input
        id={id}
        role="combobox"
        aria-autocomplete="list"
        aria-expanded={showList}
        aria-controls={showList ? listId : undefined}
        aria-activedescendant={showList && active !== -1 ? `${listId}-${active}` : undefined}
        value={text}
        onChange={e => {
          setText(e.target.value)
          setOpen(true)
          setActiveFood(null)
        }}
        onKeyDown={handleKeyDown}
      />
      {showList && (
        <ul
          id={listId}
          role="listbox"
          aria-label="Foods"
          onMouseDown={e => e.preventDefault()}
          style={{ listStyle: 'none', margin: 0, padding: 0, border: '1px solid gray' }}
        >
          {foods.map((food, i) => (
            <li
              key={food}
              id={`${listId}-${i}`}
              role="option"
              aria-selected={i === active}
              onClick={() => pick(food)}
              style={{ padding: 4, cursor: 'pointer', background: i === active ? 'lightblue' : undefined }}
            >
              <Highlight text={food} query={foodsFor} />
            </li>
          ))}
        </ul>
      )}
      <p role="status">{message}</p>
    </div>
  )
}
request re
request rea
cache hit re

Run it. Type re and wait. Type a and wait. Then press Backspace. The Console shows two requests and one cache hit re. The list for re comes back without “Searching…”, 300 ms after the key.

We typed a longer path one key at a time, waiting after each key: c, cr, cre, then back to cr and c, then forward to cr and cre. That was 7 searches. The server got 3 requests. The other 4 were cache hits.

Look at where cache.current.set is: before the ignore check. An ignored answer is old for the page, but it’s still the right answer for its own word. So we keep it. In our test of the figure’s case, the late answer for re was ignored. Then Backspace to re was a cache hit.

The marks use foodsFor, the word the shown list was found for. The hook sets it together with foods, so the list and its marks change together. Why not query? It changes 300 ms after the last key, but the list changes only when the answer arrives. We tried query: while rea loaded, the old re list was marked for rea, and “Green tea” lost its mark.

What this cache doesn’t do

  • It never forgets, so a long session could fill it. A real one keeps a limited number of words.
  • It never gets new data. If the server’s foods change, the old answer stays.
  • It doesn’t keep errors. We typed oops twice, and both went to the server. That is what you want.
  • Each autocomplete has its own cache, so two boxes ask twice. A shared one fixes that.

Data libraries, which Part 13 named, handle all of this. In an interview, say that they exist, then build the small version.

What an interviewer looks for

An interviewer watches how you work, not only the result. They look for:

  • Questions first. Match the start or anywhere? Any text, or only a listed value?
  • A working base early. Get step 1 running first. Then add one thing at a time, and run it after each.
  • The race. Do you know old answers can win? Can you fix it in the cleanup?
  • Fewer requests. Debounce, and say why the flag is still needed.
  • All the states. Loading, empty, error, and the empty box.
  • The keyboard and ARIA. Can you name the combobox pattern and its attributes?
  • Safe output. No HTML strings from data.
  • Clean pieces. Hooks with one job each: useDebouncedValue, useFoodSearch, useClickOutside.

Then come follow-up questions. Here are common ones, with short answers.

  • “The list is cut off inside a box with overflow: hidden.” Put the list in a portal, as in Part 42. Then the list is no longer inside boxRef on the page, and the contains check fails. Check the list’s own ref too.
  • “There are 10,000 suggestions.” Show only what fits, as in Part 35. Or ask the server for fewer.
  • “Check the cache before the debounce.” Then a word you’ve seen shows at once, with no 300 ms wait.
  • “Picking a food starts a new search.” It does: the box text changes, so the debounce runs. You could skip the search when the text is the food that was just picked.
  • “Make it work on a phone.” pointerdown covers fingers too. We turned on a pretend touch screen in all three test browsers. A tap on an option picked it, and a tap outside closed the list. Check the list has room above the phone’s keyboard.
  • “How would you test it?” With Testing Library, as in Part 49. Find the box by its role, combobox. Type, then wait for an option with findByRole('option', { name: 'Cream' }). findBy gives up after 1000 ms, and our server can take 1.8 seconds with the debounce. So give the test a pretend server with a short, fixed delay.

Common mistakes

A debounce made on every render

Some people write a plain debounce function and call it from onChange. It looks right:

import { useState } from 'react'

function debounce(fn: (value: string) => void, delay: number) {
  let id: ReturnType<typeof setTimeout> | undefined
  return (value: string) => {
    clearTimeout(id)
    id = setTimeout(() => fn(value), delay)
  }
}

export default function App() {
  const [text, setText] = useState('')
  const search = debounce(value => console.log('request ' + value), 300)

  return (
    <label>
      Food{' '}
      <input
        value={text}
        onChange={e => {
          setText(e.target.value)
          search(e.target.value)
        }}
      />
    </label>
  )
}
request r
request re
request rea

Run it and type rea quickly. The Console shows three requests, not one.

What goes wrong: each key press sets text, so App renders again. Each render makes a new search, with its own new timer. The old timer is still running, and nobody stops it. So nothing is debounced.

The fix: debounce a value with an Effect and a cleanup, as useDebouncedValue does. If you need a debounced function, keep it in a ref so every render uses the same one.

Closing the list on blur

onBlur runs when the box loses the focus. It seems like a good place to close the list:

import { useState } from 'react'

const FOODS = ['Bread', 'Cream', 'Ice cream']

export default function App() {
  const [text, setText] = useState('')
  const [open, setOpen] = useState(false)
  return (
    <div>
      <label>
        Food{' '}
        <input
          value={text}
          onChange={e => setText(e.target.value)}
          onFocus={() => setOpen(true)}
          onBlur={() => setOpen(false)}
        />
      </label>
      {open && (
        <ul>
          {FOODS.map(food => (
            <li key={food} onClick={() => setText(food)}>
              {food}
            </li>
          ))}
        </ul>
      )}
      <p>Picked: {text}</p>
    </div>
  )
}

Run it. Click the box, then click “Cream”. Nothing is picked.

What goes wrong: a click is a press and then a release. The focus leaves the box on the press. So onBlur closes the list right then. On the release, “Cream” is no longer on the page, so it never gets the click. In all three browsers, the click picked nothing, and “Picked:” stayed empty.

The fix: close on a press outside, with useClickOutside, and on Tab and Escape. Or keep the focus in the box with onMouseDown={e => e.preventDefault()} on the list, as our app does.

Building a regular expression from the text

A regular expression is a small pattern language for searching text. It can mark every match at once, so it looks useful here:

import { useState } from 'react'

export default function App() {
  const [text, setText] = useState('cream')
  const parts = 'Ice cream (vanilla)'.split(new RegExp(`(${text})`, 'i'))
  return (
    <div>
      <label>
        Food <input value={text} onChange={e => setText(e.target.value)} />
      </label>
      <p>{parts.map((part, i) => (i % 2 === 1 ? <mark key={i}>{part}</mark> : part))}</p>
    </div>
  )
}

Run it. “cream” is marked. Now type ( at the end of the box. The app crashes. In Chromium the error is Invalid regular expression: /(cream()/i: Unterminated group. Firefox and WebKit say it in other words.

What goes wrong: (, ., *, ? and [ mean something in a pattern. The user’s text is used as a pattern, not as plain text. Some of these crash, like (. Others give the wrong answer without a word: . matches any letter. We typed . alone, and every letter was marked, spaces too.

The fix: use indexOf on lower-case copies, then slice the original, as Highlight does. They treat the text as plain text. If you really need a pattern, escape the text first: put a \ before each sign that means something. RegExp.escape(text) does this for you. But MDN marks it “Newly available” in 2025, and says it “might not work in older devices or browsers”.

Building the highlight as an HTML string

const FOODS = ['Bread', 'Cake <b>for you</b>']

function Highlight({ text, query }: { text: string; query: string }) {
  const html = text.replace(query, `<mark>${query}</mark>`)
  return <span dangerouslySetInnerHTML={{ __html: html }} />
}

export default function App() {
  return (
    <ul>
      {FOODS.map(food => (
        <li key={food}>
          <Highlight text={food} query="re" />
        </li>
      ))}
    </ul>
  )
}

Run it. “for you” is in bold. The <b> in the food’s name became a real tag.

What goes wrong: dangerouslySetInnerHTML puts the string on the page as HTML. Anything in the data that looks like a tag becomes one. That can include a tag that runs a script. We tried a name with <img src="x" onerror="...">, and its onerror code ran in all three browsers. React’s docs say it plainly: “This is dangerous.” They call it a “security hole”.

The fix: split the text and let React put each piece on the page as text. We put the same name through our Highlight. The page showed <b> as plain letters.

Practice

Use the finished app from step 7, unless the task says otherwise.

  1. In “Try this first”, change includes to startsWith. Type c. Which foods show?
  2. Add Home and End to handleKeyDown, but only while an option is highlighted. Home highlights the first option, and End the last. Why only then?
  3. In step 3’s app, add console.log('render ' + text) in App, right after the useState line for text. Type rea quickly, all three keys within 300 ms. Then wait two seconds. How many render lines do you see? Remember Strict Mode.
  4. Type rea and wait. Clear the box and wait a moment. Type rea again, quickly. How many request lines and cache hit lines are there in total?
Answers
  1. Five foods: Carrot, Cheese, Cherry, Corn and Cream. With includes, “c” also matched Ice cream, Peach and Rice.
  2. For example: else if ((e.key === 'Home' || e.key === 'End') && active !== -1) { e.preventDefault(); setActiveFood(e.key === 'Home' ? foods[0] : foods[foods.length - 1]) }. With nothing highlighted, Home and End move the cursor. The W3C pattern says not to get in the way of “text editing functions”. In our test, Home with nothing highlighted was not stopped. With “Bread” highlighted, End moved to “Ice cream”. The W3C example lets Home and End always move the cursor. Both are allowed.
  3. 12 lines. App renders 6 times. It renders once when it appears and once for each of the 3 keys. Then it renders when the debounced value changes, and when the answer arrives. Strict Mode calls App twice for each render, so each line shows twice.
  4. One request rea and one cache hit rea. Clearing the box makes the query empty, and an empty query is never searched. Typing rea fast gives one debounced search, and its answer is in the cache. There are no Strict Mode doubles: the Effects ran again only when query changed.

Interview questions

Try to answer each one out loud before you open the answer.

Two requests are on the way. How do you make sure the page shows the newest one?

Make each answer check that it is still wanted. In an Effect, keep a flag, let ignore = false, and set it to true in the cleanup. React runs the cleanup before the Effect runs again for the new query. So an old answer finds its flag set, and does nothing. Or pass an AbortController‘s signal to fetch and call abort() in the cleanup. That also stops the download.

A strong answer adds that debouncing alone doesn’t fix this. If the user pauses longer than the delay, two requests are still on the way. They can also say that data libraries handle this for you.

What is debouncing? How is it different from useDeferredValue?

Debouncing waits until a value has stayed the same for a set time, then uses it. Typing cream with 150 ms between keys sent 5 requests with no debounce, and 1 with a 300 ms debounce. useDeferredValue doesn’t wait for a set time. It lets React show the old value while it renders with the new one in the background. React’s docs say it “does not by itself prevent extra network requests”.

A strong answer says when to use which. Debounce work that happens outside rendering, like requests. Use useDeferredValue when rendering itself is slow. The docs also pair it with <Suspense> from Part 31: the old results stay on the page while new ones load. And they say: “You can also use these techniques together.”

Why use aria-activedescendant here, and not move the focus to each option?

The user is typing in the box. The focus must stay there, or the next letter goes nowhere. aria-activedescendant on the box names the highlighted option’s id. A screen reader then reads that option as if it had the focus. The W3C combobox pattern says “DOM focus remains on the combobox”.

A strong answer names the cost. You draw the highlight yourself, and you scroll the option into view yourself. The option also needs aria-selected="true".

Which ARIA roles and attributes does a combobox need?

The input gets role="combobox", aria-expanded (true or false), aria-controls with the list’s id while it shows, and aria-autocomplete="list". The list gets role="listbox" and a name. Each suggestion gets role="option". The highlighted one has aria-selected="true", and its id is in the input’s aria-activedescendant. The input keeps a visible <label>.

A strong answer also gives the keys: Down and Up move, Enter picks, Escape closes. It adds that a live region can say how many results there are.

How do you highlight the matching part of each suggestion safely?

Find where the match starts with indexOf on lower-case copies. Then slice the original text into before, match and after, and put the match in a <mark>. React puts each piece on the page as text, so a < in the data can’t become a tag.

A strong answer explains why not to use dangerouslySetInnerHTML: the data could carry a tag that runs a script. It also says why not to build a regular expression from the user’s text: ( makes it throw.

Where would you keep a cache of answers, and what can go wrong with it?

In a Map inside a ref, keyed by the lower-case query. It lasts as long as the component, and changing it doesn’t re-render. Check it before sending a request. Save an answer even if the Effect has ignored it, because it’s still right for its own word.

A strong answer names the limits: it grows forever, it never gets new data, and two boxes don’t share it. A real cache keeps a limited number of words and drops old ones after a while. Libraries like TanStack Query do that, and share one cache across the app. With AbortController, a stopped request never answers, so there is no late answer to save.

Sources

  • W3C ARIA Authoring Practices Guide, Combobox Pattern: the definition, the four kinds of autocomplete, the keys, and the roles and attributes quoted above.
  • W3C ARIA Authoring Practices Guide, Editable Combobox With List Autocomplete and its script: the wrap at both ends, Escape clearing a closed box, and scrolling the active option into view.
  • MDN, aria-activedescendant: the roles it works on, combobox among them.
  • react.dev, You Might Not Need an Effect: working out values while rendering, and the search box race condition with its ignore fix.
  • react.dev, useDeferredValue: how it differs from debouncing, and that it doesn’t prevent extra requests.
  • react.dev, Common components: dangerouslySetInnerHTML and why it is dangerous.
  • MDN, <mark>, AbortController and Map.
  • The race counts, request counts, cache hits, page text and error messages come from running React 19.3.0 for this post. The focus, key and click results come from headless Chromium, Firefox and WebKit, and the accessibility tree from Chromium.
  • This part follows the Auto-complete (Typeahead) kata in react-katas.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.