---
id: "packages/active-ui/async-combobox"
title: "AsyncCombobox"
description: "A searchable choice from a list the server searches, with its own loading, empty and error states and no stale results."
url: "/packages/active-ui/async-combobox"
locale: "en"
revision: "a76cf729fff9edb5"
---

# AsyncCombobox

Use `AsyncCombobox` when the list to choose from lives on the server: people, organisations,
records. Your application supplies a search function; the component runs it as the reader types and
shows the result. For a list you already have, use
[SearchCombobox](/packages/active-ui/search-combobox).

**Example: A person found by a simulated server search**

Simulation: A server imitated in your browser: shows the interaction, not a working server integration. Source: `examples/ui/async-combobox.tsx`.

```tsx
// apps/docs/examples/ui/async-combobox.tsx
'use client';

import {useState} from 'react';

import {
  AsyncCombobox,
  type AsyncComboboxOption,
} from '@workspace/active-ui/components/composed/async-combobox';
import {
  Field,
  FieldDescription,
  FieldLabel,
} from '@workspace/active-ui/components/primitives/field';

// Fictitious people. The "server" below is a simulation in the browser.
const PEOPLE = [
  'Ada Jansen',
  'Bram de Vries',
  'Chloé Martin',
  'Daan Bakker',
  'Eva Smit',
  'Fatima El Amrani',
  'Gijs Visser',
  'Hanna Meijer',
].map((name, index) => ({value: `person-${index + 1}`, label: name}));

/** A search that takes 600 ms, honours the signal, and fails on "error". */
function simulatedSearch(query: string, signal: AbortSignal) {
  return new Promise<readonly AsyncComboboxOption[]>((resolve, reject) => {
    const timer = window.setTimeout(() => {
      if (query.toLowerCase().includes('error')) {
        reject(new Error('Simulated failure'));
        return;
      }
      const needle = query.toLowerCase();
      resolve(
        PEOPLE.filter((person) => person.label.toLowerCase().includes(needle)),
      );
    }, 600);
    signal.addEventListener('abort', () => {
      window.clearTimeout(timer);
      reject(new DOMException('Aborted', 'AbortError'));
    });
  });
}

/**
 * A choice from a list the server searches. Type two letters to search;
 * typing "error" shows the failed state. The value is the whole option, since
 * the chosen person is usually not in the next result.
 */
export default function AsyncComboboxExample() {
  const [person, setPerson] = useState<AsyncComboboxOption | null>(null);
  return (
    <Field className="w-full max-w-xs">
      <FieldLabel htmlFor="contact-person">Contact person</FieldLabel>
      <AsyncCombobox
        id="contact-person"
        value={person}
        onValueChange={setPerson}
        onSearch={simulatedSearch}
        placeholder="Choose a person"
        searchLabel="Search people"
        searchPlaceholder="Type a name…"
        typeToSearchText="Type at least two letters."
        loadingText="Searching…"
        emptyText="Nobody found."
        errorText="The search failed. Try again."
        aria-describedby="contact-person-value"
      />
      <FieldDescription id="contact-person-value">
        Value: <code>{person ? person.value : 'null'}</code>
      </FieldDescription>
    </Field>
  );
}
```

## What the composition adds

- **The lifecycle of a search**: it waits `debounceMs` (300) after the last key, starts at
  `minQueryLength` characters (2), and aborts and cleans up on close.
- **No stale results.** Each request has a sequence number; a response that does not belong to the
  latest request is never shown, whether or not your function honours the `AbortSignal`. Results of
  an earlier search term are discarded as soon as the term changes.
- **Four states with your texts**: not searched yet, searching, nothing found, failed, announced in
  one polite live region. A failed search is a rejected promise; the error itself is never shown.
- **The value is the whole option**, because the chosen option is usually not in the next result.
  Store `value.value`, and keep the object in state.

No pagination, no cache, no groups, no clear button, no multiple choice.

## Usage

```tsx
import {
  AsyncCombobox,
  type AsyncComboboxOption,
} from '@workspace/active-ui/components/composed/async-combobox';

const [person, setPerson] = useState<AsyncComboboxOption | null>(null);

<AsyncCombobox
  value={person}
  onValueChange={setPerson}
  onSearch={(query, signal) => searchPeople(query, {signal})}
  placeholder="Choose a person"
  searchLabel="Search people"
  searchPlaceholder="Type a name…"
  typeToSearchText="Type at least two letters."
  loadingText="Searching…"
  emptyText="Nobody found."
  errorText="The search failed. Try again."
/>;
```

The example on this page searches a fixed list in the browser with a delay of 600 ms; typing
`error` makes it fail.

## What your application provides

- The search: the request, its permissions, the limit on the number of results, and logging a
  failure before rejecting.
- The texts of the four states, translated. Do not debounce in your function as well.

## Accessibility and keyboard

- Name the button with `FieldLabel htmlFor` its `id`.
- The state texts are announced politely; `ArrowUp`, `ArrowDown`, `Enter` and `Escape` work as in
  [SearchCombobox](/packages/active-ui/search-combobox).

## API

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `emptyText` | `string` | yes | Text when the search was valid and returned nothing. |
| `errorText` | `string` | yes | Text when the search failed. Generic and translated: the error itself is never shown. |
| `loadingText` | `string` | yes | Text while a search is running or waiting. |
| `onSearch` | `AsyncComboboxSearch` | yes | Searches server-side and returns the options that match `query`.  The component filters **nothing** afterwards: what comes back here is what the user sees, in the order in which it arrives. |
| `onValueChange` | `(option: AsyncComboboxOption \| null) => void` | yes | Receives the newly selected option, or `null`. |
| `placeholder` | `string` | yes | Text on the button while nothing is selected. Supplied translated. |
| `searchLabel` | `string` | yes | Accessible name of the search field inside the menu. Supplied translated.  Required: the field has no visible label, and its placeholder is not a name. The caller owns the wording, so two pickers on one page can be told apart ("Search category", "Search participant"). |
| `searchPlaceholder` | `string` | yes | Text in the search field inside the menu. Supplied translated. |
| `typeToSearchText` | `string` | yes | Text while the search term is shorter than `minQueryLength`. |
| `value` | `AsyncComboboxOption \| null` | yes | The selected option as a **whole object**, or `null`.  This is the only point where the contract deviates from `SearchCombobox`, and it follows from being remote: with a local list the component can map a key to a label because it has the list, and with a remote list it cannot, since the selected option is usually not in the current search result. Store `value.value`; keep the object alongside it in state. |
| `debounceMs` | `number` | no | The delay between the last keystroke and the request, in milliseconds.  **The caller does not debounce as well**: that would stack two delays. |
| `minQueryLength` | `number` | no | The number of characters from which a search starts.  `0` means: search as soon as the menu opens, even with an empty search term. |

**AsyncComboboxOption**

```ts
interface AsyncComboboxOption {
  value: string;
  label: string;
  disabled?: boolean;
}
```

**AsyncComboboxSearch**

```ts
type AsyncComboboxSearch = (
  query: string,
  signal: AbortSignal,
) => Promise<readonly AsyncComboboxOption[]>;
```

The search function supplied by the caller.

`signal` is **not optional**: the component always supplies one. A caller
that cannot cancel (a Next.js server action, for example) simply ignores it. That is safe,
because ignoring stale responses does not depend on `abort` but on a
generation counter; see the notes on {@link AsyncCombobox}.

A failed search is a rejected promise. Anyone who wants to log the error does
so here: this is the caller's function, so catch it here and rethrow it. The
component never renders the error object.

**AsyncComboboxProps**

```ts
interface AsyncComboboxProps extends Omit<
  React.ComponentProps<'button'>,
  'value' | 'defaultValue' | 'onChange' | 'children'
> {
  onSearch: AsyncComboboxSearch;
  value: AsyncComboboxOption | null;
  onValueChange: (option: AsyncComboboxOption | null) => void;
  placeholder: string;
  searchLabel: string;
  searchPlaceholder: string;
  typeToSearchText: string;
  loadingText: string;
  emptyText: string;
  errorText: string;
  minQueryLength?: number;
  debounceMs?: number;
}
```
