# React Combobox: Async Search Without Race Conditions

> Build a React combobox that searches a server without race conditions, ignores accents, follows the ARIA keys and opens a full-height search sheet on phones.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 10 min
- Canonical: https://wingo-ui.com/blog/react-combobox

## TL;DR

A React combobox is a text field with a popup list of suggestions: people type, the list filters, and Enter or a click fills the field. Use it when typing is faster than scrolling, when the options come from a server, or when free text is allowed; use a select for a known list people scan, and a listbox when the options should stay visible on the page. For server search, debounce the query, abort the older request, keep the old results on screen while loading, and fold case and diacritics on both sides.

A React combobox is the field where people type "san" and pick San Diego from a list under it. The first version takes an afternoon: an input, a filtered array, a popup. Then the list comes from a server and an old response overwrites a newer one, "jose" does not find "José", the arrow keys scroll the page instead of the list, and on a phone the popup sits under the keyboard. This guide covers when a combobox is the right control instead of a [Select](https://wingo-ui.com/components/select) or a [Listbox](https://wingo-ui.com/components/listbox), and how to build a server-backed autocomplete with the [Combobox](https://wingo-ui.com/components/combobox) from Wingo UI, the library we build.

## What is a combobox, and how is it different from a select or a listbox?

A combobox is a text field with a popup of suggestions, a select is a button that opens a list, and a listbox is the list itself, always on the page. All three use the same ARIA building blocks. The difference is where the choice happens and whether people type.

The [WAI-ARIA combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) defines a combobox as an input with a popup, usually a listbox, that helps people set its value. An editable combobox has a text field. A select-only combobox has no text field and behaves like a native `<select>`. A plain listbox is the list itself, always on the page, with no popup.

| Control | What people do | Value | Use it for |
|---|---|---|---|
| [Combobox](https://wingo-ui.com/components/combobox) (Pro) | Type, then pick a suggestion | An option, or free text when allowed | Thousands of options, server search, addresses, cities, people |
| [Select](https://wingo-ui.com/components/select) (Free) | Open, scroll or search, pick | Always one of the options | A known list people scan: states, categories, time zones |
| [Listbox](https://wingo-ui.com/components/listbox) (Free) | Pick from a list that is always visible | One option, or several with `multiple` | Filters in a sidebar, a picker inside a sheet or a step |

The rule of thumb we use: if people know what they are looking for and typing it is faster than scrolling, use a combobox. If they need to see the options to decide, use a select. If the options are the main content of that part of the screen, use a listbox. Two to five options people should compare belong in a radio group or a segmented control, as the [React form components guide](https://wingo-ui.com/blog/react-form-components-guide) explains.

## What should a React combobox with search handle?

A React combobox with search has to handle seven things beyond filtering an array: spelling, aliases, async results, the keyboard, committing the text, phones and forms. Our checklist:

- **Matching people's spelling.** Case and diacritics never matter, every word must match, best matches first, matched letters in bold.
- **Aliases.** "St. Louis" should find Saint Louis and "NY" should find New York. Search terms that are not in the label go in the option's `keywords`, which rank a little below a label match.
- **Async results.** Debounce, cancel the older request, keep the old rows while the new ones load, show an error with a retry.
- **Keyboard.** The ARIA keys, with focus staying in the input.
- **Committing the text.** On blur the text snaps back to the chosen option or becomes the value, never something in between.
- **Phones.** A popup under a text field ends up under the keyboard.
- **Forms.** The option value, not the label people read, reaches `FormData` and your form library.

## How do I build an async React combobox with server search?

Pass a `loadOptions` function and let the component own the timing. Install the Combobox first. The CLI copies the source into your project and adds what it imports (the Listbox, the field anatomy, the text matcher):

```bash
npx wingo-ui@latest add combobox
```

The `loadOptions` prop receives the query and an `AbortSignal`, and returns the options. This is the full field:

```tsx
"use client"

import { useState } from "react"
import { MapPin } from "lucide-react"
import { Combobox, type ComboboxItems } from "@/components/ui/combobox"

async function searchCities(query: string, { signal }: { signal: AbortSignal }): Promise<ComboboxItems> {
  const response = await fetch(`/api/cities?q=${encodeURIComponent(query)}`, { signal })
  if (!response.ok) throw new Error(`City search failed: ${response.status}`)
  const cities: { id: string; name: string; state: string }[] = await response.json()
  return cities.map((city) => ({ value: city.id, label: city.name, description: city.state }))
}

export function CityField() {
  const [city, setCity] = useState("")
  return (
    <Combobox
      label="City"
      placeholder="Search for a city"
      leftIcon={<MapPin />}
      loadOptions={searchCities}
      minQueryLength={1}
      value={city}
      onValueChange={setCity}
      emptyText="No cities found"
      errorText="Could not load cities"
      sheetTitle="Search cities"
      name="city"
    />
  )
}
```

The hard part of React combobox async search is timing, and the component handles it:

- It waits for `debounce` milliseconds of quiet (250 by default) before calling `loadOptions`, and not before `minQueryLength` characters. The very first load runs at once, so the first results do not lag.
- A newer query aborts the older request through the signal you passed to `fetch`, and an answer that arrives late never overwrites a newer one.
- The previous results stay visible while the next ones load, with a spinner in the field, so the list does not flash empty on every keystroke.
- The last 20 answers are cached by trimmed query, so Backspace back to a previous query is instant. Failed requests are not cached.
- A rejected promise shows `errorText` with a retry button (`retryLabel`), and the last results stay.

Throwing on a non-OK response matters: a 500 that resolves with an error body would otherwise render as "No cities found", which tells people the city does not exist.

The demo's fake API answers after 400ms, so you can watch the old results stay while the new ones load.

> Live demo (Combobox): Type "san", then "st louis": the old results stay while the server answers, and the matched letters turn bold. Try it at [Combobox](https://wingo-ui.com/components/combobox) and install it with `npx wingo-ui@latest add combobox`.

## How do I make the search ignore accents and case?

Fold both sides the same way. Lowercase, decompose with Unicode NFD, drop the combining marks: "José" becomes "jose", "Zürich" becomes "zurich". Letters that do not decompose, like "ß" or "ø", need a small table.

The Combobox does this for local options through the `text-match` helper, which ships with it and has no React dependency. The helper is free on its own (`npx wingo-ui@latest add text-match`), and you can import the same function in a Next.js route handler to get identical matching on the server:

```ts
// app/api/cities/route.ts
import { filterByText } from "@/lib/text-match"
import { CITIES } from "@/data/cities"

export async function GET(request: Request) {
  const query = new URL(request.url).searchParams.get("q") ?? ""
  const results = filterByText(CITIES, query, (city) => [city.name, city.state], { limit: 20 })
  return Response.json(results.map((result) => result.item))
}
```

`filterByText` returns the items best first: an exact match, then a prefix of the whole text, then a word prefix, then a match inside a word. Keywords after the label count a little lower. `CITIES` is your own array of `{ id, name, state }`.

For a database, fold in the query instead. PostgreSQL ships the [unaccent extension](https://www.postgresql.org/docs/current/unaccent.html), whose `unaccent()` function strips the marks (`unaccent('Hôtel')` returns `Hotel`). Store the folded, lowercased name in its own column and index that, so a search does not fold every row on every keystroke.

Two more props change the matching on the client. `fuzzy` lets the letters have gaps, so "sfco" finds San Francisco. `filter={false}` tells it your options are already filtered.

## Which keyboard keys should a combobox support?

A combobox should support the arrow keys, Enter, Escape and Tab while focus stays in the text field, as the WAI-ARIA pattern describes. The Combobox maps them like this:

| Key | What it does |
|---|---|
| ArrowDown | Opens the list (every option when the field is empty) and moves down |
| ArrowUp | Moves up |
| Alt+ArrowDown | Opens the list without moving the highlight |
| PageUp / PageDown | Jump a screen |
| Enter | Picks the highlighted option |
| Escape | Closes the list; a second Escape clears the field |
| Home / End | Move the caret in the text, as in any input |
| Tab | Closes the list and moves on |

Focus stays in the input the whole time. The input has `role="combobox"`, `aria-autocomplete="list"`, `aria-expanded`, `aria-controls` pointing at the list while it is open, and `aria-activedescendant` pointing at the highlighted row, so screen readers announce the row while the caret stays where you were typing.

`autoHighlight` (on by default) highlights the first match so Enter picks it. Turn it off when the field accepts free text, because then Enter should keep what was typed.

## When should the combobox accept free text?

Accept free text when any answer is valid and the suggestions only save typing; require an option when the value has to exist in your data. By default the text snaps back to the chosen option when the field loses focus, or empties if nothing was chosen. That is right for a city that has to exist in your database.

A street address is different: suggestions help, but any street is valid. Pass `allowCustomValue` and the typed text becomes the value on blur or Enter. This is the React autocomplete input most address forms need. Base UI handles the same case with a separate Autocomplete component, because its Combobox does not allow free-form text.

When people should add the missing option to the list, use `creatable`. A "Create" row appears when nothing matches exactly, and `onCreate` can return a promise. The row shows a spinner until it resolves, then the returned value is selected:

```tsx
"use client"

import { useState } from "react"
import { Combobox, type ComboboxOption } from "@/components/ui/combobox"

async function createMake(name: string): Promise<{ id: string }> {
  const response = await fetch("/api/makes", { method: "POST", body: JSON.stringify({ name }) })
  if (!response.ok) throw new Error(`Could not create ${name}`)
  return response.json()
}

export function MakeField({ initialMakes }: { initialMakes: ComboboxOption[] }) {
  const [makes, setMakes] = useState(initialMakes)
  const [make, setMake] = useState("")
  return (
    <Combobox
      label="Car make"
      options={makes}
      value={make}
      onValueChange={setMake}
      creatable
      onCreate={async (name) => {
        const created = await createMake(name)
        setMakes((current) => [...current, { value: created.id, label: name }])
        return created.id
      }}
    />
  )
}
```

Add the new option to `options` yourself, as above. The Combobox selects the returned value, but the list is yours.

## How does a combobox work on a phone?

On a phone the Combobox opens a full-height search sheet instead of a popup. The keyboard takes the lower half of the screen, so a popup under the field would sit under it. Below 768px the field on the page never raises the keyboard itself. A tap opens the sheet with a search box at the top, which takes focus so the keyboard comes up there, a Cancel button (`cancelLabel`), and 48px rows. A tap on a result fills the field and closes the sheet, and the keyboard's search key picks the first result. Pass `responsive={false}` to keep the popup.

The Select goes the other way: a bottom sheet titled with the label, and its search row waits for a tap, because people usually scroll a short list instead of typing.

> Live demo (Select): Open the select and type "or": the search row filters the states, and on a phone the list becomes a bottom sheet. Try it at [Select](https://wingo-ui.com/components/select) and install it with `npx wingo-ui@latest add select`.

## Does shadcn/ui have a combobox with async search?

Yes, shadcn/ui has a combobox, but the shadcn combobox docs have no server search example, so the async part is code you write. As of October 2026, the [shadcn/ui Combobox](https://ui.shadcn.com/docs/components/combobox) page offers Base UI, Radix UI and React Aria versions. The default Base UI version links to the [Base UI Combobox](https://base-ui.com/react/components/combobox) for its API and shows groups, a clear button, an input group addon and multiple selection with chips. It has no example for server search or for creating a missing option.

The older recipe, which many tutorials still copy and which still sits in the shadcn repo as [combobox-demo.tsx](https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/new-york-v4/examples/combobox-demo.tsx), composes a Popover with the Command component (cmdk). Server search there means turning off cmdk's built-in filter with `shouldFilter={false}`, which the [cmdk README](https://github.com/pacocoursey/cmdk) documents, then rendering the results you fetched yourself.

For async search, Base UI's own examples pass `filter={null}`, start a search in `onInputValueChange`, abort the previous `AbortController` so a stale answer is dropped, and render loading and error text from their own state. That is example code you copy and maintain. It has no debounce, and the signal is not passed to the fake fetch, so an older request still runs to the end. If you go this way, add the debounce, pass the signal to `fetch`, and decide what the field does on a phone. [Our command palette guide](https://wingo-ui.com/blog/react-command-palette) covers the related case of searching actions instead of values.

## When is a listbox the better control?

When the options are the main content of that area: filters in a sidebar, a step in an onboarding flow, a picker inside an open sheet. A popup there adds a tap and hides the choice. The Listbox renders the same rows inline, with an optional search row (`search`), `multiple` with checkboxes, `loadOptions` for server search with the same debounce and cache, and `creatable`.

> Live demo (Listbox): Type a state or its code in the search row: OR finds Oregon, and the arrow keys move through the rows. Try it at [Listbox](https://wingo-ui.com/components/listbox) and install it with `npx wingo-ui@latest add listbox`.

The Select and the Listbox are free. For several values in a popup, the [React multi select guide](https://wingo-ui.com/blog/react-multi-select) covers the [Multi Select](https://wingo-ui.com/components/multi-select), and the [Tag Input guide](https://wingo-ui.com/blog/react-tag-input) covers the case where people type values of their own.

## What are the common combobox pitfalls?

- **Highlighting the first match in a free text field.** With `allowCustomValue`, turn off `autoHighlight`, or Enter replaces what people typed.
- **A value without a label.** When the controlled `value` is not in the current results, the field needs the option's text. The Combobox keeps the text of options it has seen, and shows the raw value otherwise.
- **Submitting the label instead of the value.** Pass `name` and the option value (`san-diego`, not "San Diego") goes out through a hidden input, so `FormData` and Server Actions get the id. react-hook-form's `register` is different: it reads the text input and its native `onChange`, so it records what is typed. That is right with `allowCustomValue`. For option values, use `Controller` and wire `value` and `onValueChange`.
- **Stale cached answers.** With `loadOptions`, the 20-query cache lives as long as the field is mounted and has no expiry. After a write that changes what the server returns, a query people already typed shows the old answer, so give the Combobox a new `key` when your data changes.

## Where do I go from here?

Install the Combobox with `npx wingo-ui@latest add combobox` and point `loadOptions` at your search endpoint. It is a Pro component ([see pricing](https://wingo-ui.com/pricing)); the Select and the Listbox are free. More guides like this one are in [the component guides category](https://wingo-ui.com/blog/category/components).

## Components in this post

- [Combobox](https://wingo-ui.com/components/combobox) (Pro): A React combobox and autocomplete: type to filter a list or search a server, with matches in bold, a create row and a phone search sheet. Install: `npx wingo-ui@latest add combobox`
- [Select](https://wingo-ui.com/components/select) (Free): A React select with search, groups, icons, virtualized rows and a hidden native select for forms. Install: `npx wingo-ui@latest add select`
- [Listbox](https://wingo-ui.com/components/listbox) (Free): A React listbox: a searchable list of options for single or multiple choice, with groups, async loading, create-new and virtualized rows. Install: `npx wingo-ui@latest add listbox`
- [Multi Select](https://wingo-ui.com/components/multi-select) (Pro): A React multi select: pick several options shown as chips with a +N overflow, with search, select all, a maximum and async options. Install: `npx wingo-ui@latest add multi-select`
- [Text match](https://wingo-ui.com/components/text-match) (Free): Search that ignores case and diacritics, ranks the best matches first and returns the ranges to highlight. Install: `npx wingo-ui@latest add text-match`

## FAQ

### What is the difference between a combobox and a select?

A combobox has an editable text field: you type to filter the options, and some comboboxes accept text that is not in the list. A select is a button that opens a list you scroll or search, and its value is always one of the options. The WAI-ARIA Authoring Practices call a combobox without a text field a select-only combobox and compare it to an HTML select.

### Is a combobox the same as an autocomplete?

Mostly. Autocomplete describes the behavior (suggestions as you type) and combobox is the ARIA role of the input that shows them. Some libraries split the two: as of October 2026, Base UI's docs say its Combobox does not allow free-form text and point search widgets to its Autocomplete.

### How do I make a React combobox async?

Wait for a short pause in typing (250ms is a good default), send the query with an AbortSignal, abort the previous request when a new one starts, and ignore any answer that arrives after a newer query. Keep the previous results visible with a spinner instead of clearing the list on every keystroke.

### How do I make a combobox ignore accents?

Normalize both the query and the option text before comparing: lowercase, Unicode NFD, then strip the combining marks, so "jose" matches "José". Letters such as ß and ø do not decompose and need a small lookup table. On the server, run the same function or use the database's own tool, such as PostgreSQL's unaccent extension.

### Does shadcn/ui have a combobox?

Yes. As of October 2026 the shadcn/ui Combobox docs offer Base UI, Radix UI and React Aria versions, and the default Base UI page covers groups, a clear button and multiple selection with chips. The page has no example for server search or for creating new options, so that part is your own code.

---

Source: https://wingo-ui.com/blog/react-combobox
