AsyncCombobox
A searchable choice from a list the server searches, with its own loading, empty and error states and no stale results.
Use Async 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.
A person found by a simulated server searchSimulationA server imitated in your browser: shows the interaction, not a working server integration.
Value: null
// 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
debounce(300) after the last key, starts atMs mincharacters (2), and aborts and cleans up on close.Query Length 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
Abort. Results of an earlier search term are discarded as soon as the term changes.Signal 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., and keep the object in state.value
No pagination, no cache, no groups, no clear button, no multiple choice.
Usage#
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
FielditsLabel html For id.The state texts are announced politely;
Arrow,Up Arrow,Down EnterandEscapework as in SearchCombobox.
API#
Import from @workspace/
Props
- Type
string
Text when the search was valid and returned nothing.
- Type
string
Text when the search failed. Generic and translated: the error itself is never shown.
- Type
string
Text while a search is running or waiting.
- Type
string
Text on the button while nothing is selected. Supplied translated.
- Type
string
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").
- Type
string
Text in the search field inside the menu. Supplied translated.
- Type
string
Text while the search term is shorter than min.
- Type
AsyncCombobox Option | null
The selected option as a whole object, or null.
This is the only point where the contract deviates from Search, 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.; keep the object alongside it in state.
- Type
number- Default
300
The delay between the last keystroke and the request, in milliseconds.
The caller does not debounce as well: that would stack two delays.
- Type
number- Default
2
The number of characters from which a search starts.
0 means: search as soon as the menu opens, even with an empty search term.
Events
Called by the component when something happens.
- Type
AsyncCombobox Search
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.
Other props, such as the standard attributes of the underlying element, className and ref, are passed through.
Types
interface AsyncComboboxOption {
value: string;
label: string;
disabled?: boolean;
}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 Async.
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.
type AsyncComboboxSearch = (
query: string,
signal: AbortSignal,
) => Promise<readonly AsyncComboboxOption[]>;The props of Async, each described in its prop list on this page.
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;
}