Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Component guides
  4. React Combobox: Async Search Without Race Conditions
Auf Deutsch lesen
  1. Blog
  2. Component guides
  3. React Combobox: Async Search Without Race Conditions
Component guides
Component guides

React Combobox: Async Search Without Race Conditions

SR
Serban Rusu · Founder of Wingo UI
Oct 16, 2026 · 10 min read

On this page

0%
  1. What is a combobox, and how is it different from a select or a listbox?
  2. What should a React combobox with search handle?
  3. How do I build an async React combobox with server search?
  4. How do I make the search ignore accents and case?
  5. Which keyboard keys should a combobox support?
  6. When should the combobox accept free text?
  7. How does a combobox work on a phone?
  8. Does shadcn/ui have a combobox with async search?
  9. When is a listbox the better control?
  10. What are the common combobox pitfalls?
  11. Where do I go from here?
  12. FAQ
    1. What is the difference between a combobox and a select?
    2. Is a combobox the same as an autocomplete?
    3. How do I make a React combobox async?
    4. How do I make a combobox ignore accents?
    5. Does shadcn/ui have a 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.

Published Oct 16, 2026

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 or a Listbox, and how to build a server-backed autocomplete with the 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 (opens in a new tab) 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.

ControlWhat people doValueUse it for
Combobox (Pro)Type, then pick a suggestionAn option, or free text when allowedThousands of options, server search, addresses, cities, people
Select (Free)Open, scroll or search, pickAlways one of the optionsA known list people scan: states, categories, time zones
Listbox (Free)Pick from a list that is always visibleOne option, or several with multipleFilters 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 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.

Type "san", then "st louis": the old results stay while the server answers, and the matched letters turn bold
$ npx wingo-ui@latest add combobox
ProCombobox docs

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 (opens in a new tab), 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:

KeyWhat it does
ArrowDownOpens the list (every option when the field is empty) and moves down
ArrowUpMoves up
Alt+ArrowDownOpens the list without moving the highlight
PageUp / PageDownJump a screen
EnterPicks the highlighted option
EscapeCloses the list; a second Escape clears the field
Home / EndMove the caret in the text, as in any input
TabCloses 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.

Open the select and type "or": the search row filters the states, and on a phone the list becomes a bottom sheet
$ npx wingo-ui@latest add select
FreeSelect docs

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 (opens in a new tab) page offers Base UI, Radix UI and React Aria versions. The default Base UI version links to the Base UI Combobox (opens in a new tab) 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 (opens in a new tab), 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 (opens in a new tab) 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 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.

Type a state or its code in the search row: OR finds Oregon, and the arrow keys move through the rows
$ npx wingo-ui@latest add listbox
FreeListbox docs

The Select and the Listbox are free. For several values in a popup, the React multi select guide covers the Multi Select, and the Tag Input guide 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); the Select and the Listbox are free. More guides like this one are in the component guides category.

Components in this post

  • Combobox

    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.

    Pro
  • Select

    A React select with search, groups, icons, virtualized rows and a hidden native select for forms.

    Free
  • Listbox

    A React listbox: a searchable list of options for single or multiple choice, with groups, async loading, create-new and virtualized rows.

    Free
  • Multi Select

    A React multi select: pick several options shown as chips with a +N overflow, with search, select all, a maximum and async options.

    Pro
  • Text match

    Search that ignores case and diacritics, ranks the best matches first and returns the ranges to highlight.

    Free

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.

  • Combobox
  • Autocomplete
  • Forms
  • Accessibility
  • Mobile UI

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Component guides

React Multi Select: Search, Chips, Select All and Mobile

Build a React multi select that holds up with real data: chips that fold into +N, search, select all over filtered rows, a maximum and a phone bottom sheet.

SRSerban Rusu·Oct 16, 2026·10 min read
Component guides

React Tag Input: Chips from Typing, Paste and Suggestions

A React tag input guide: turn typed and pasted text into chips with suggestions, limits and duplicate handling, plus when a multi-select is the better control.

SRSerban Rusu·Oct 13, 2026·8 min read
Component guides

React Form Components: Accessible Fields, Zod and Mobile

React form components that share one anatomy (label, hint, error, counter), plug into react-hook-form and zod, and behave right on a phone, with full code.

SRSerban Rusu·Oct 9, 2026·25 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI