ComponentsAsyncSelect
AsyncSelect
Let users choose from options loaded by your app, with loading and retry states.
On this page
Install component source from the public registry, or use the local workspace package. Follow the setup guide first. The npm package is not published yet.
View registry files- React Aria
(react-aria)Accessibility hooks and overlay utilities used by the implementation.
- React Aria Components
(react-aria-components)Accessible interaction primitives, including keyboard behavior, focus handling, and ARIA semantics.
Shared foundation: React, clsx for conditional classes, tailwind-merge for resolving utility-class conflicts. Styles use Tailwind CSS and Dethink CSS variables.
Based on this component’s source and registry composition. Libraries used only in showcase examples are excluded.
Import the component into your page or component file.
import { AsyncSelect } from "@dethink/components";Your app owns fetching and query state. The delayed lookup demonstrates pending requests, empty results, and retry without letting older responses overwrite a newer search.
Delayed account lookup
Search accounts with a simulated 600ms response. Trigger a connection error, then retry to recover.
Show sourceexamples/async-select/basic.tsx
"use client";
import { useEffect, useState } from "react";
import { AsyncSelect, Button } from "@dethink/components";
const accountItems = [
{ label: "Acme Operations", value: "acme" },
{ label: "Dethink Labs", value: "dethink" },
{ label: "Northstar Systems", value: "northstar" },
{ label: "Signal Foundry", value: "signal" },
];
export function AsyncSelectBasic() {
const [query, setQuery] = useState("");
const [value, setValue] = useState<string | null>(null);
const [fail, setFail] = useState(false);
const [attempt, setAttempt] = useState(0);
const request = JSON.stringify([query, fail, attempt]);
const [response, setResponse] = useState<{
request: string;
items: typeof accountItems;
error?: string;
}>();
useEffect(() => {
// The app owns requests. Cancel stale work so older results cannot win.
// Replace this timer with fetch(url, { signal }) in a real integration.
const timer = setTimeout(() => {
setResponse({
request,
items: fail
? []
: accountItems.filter((item) =>
item.label
.toLocaleLowerCase()
.includes(query.trim().toLocaleLowerCase()),
),
error: fail ? "Could not load accounts. Try again." : undefined,
});
}, 600);
return () => clearTimeout(timer);
}, [request, query, fail]);
const current = response?.request === request ? response : undefined;
return (
<div
data-slot="async-select-demo"
data-ready={Boolean(current)}
className="mx-auto w-full max-w-sm space-y-3"
>
<AsyncSelect
inputValue={query}
value={value}
onValueChange={setValue}
selectedItems={accountItems.filter((item) => item.value === value)}
items={current?.items ?? []}
loading={!current}
error={current?.error}
onRetry={() => {
setFail(false);
setAttempt((count) => count + 1);
}}
label="Account"
name="account"
onInputValueChange={setQuery}
placeholder="Search accounts"
description="Results take 600ms. Try a name, an unmatched query, or a connection error."
/>
<Button
size="sm"
variant="outline"
onClick={() => {
setFail(true);
setAttempt((count) => count + 1);
}}
>
Simulate connection error
</Button>
</div>
);
}Multiple values
Multiple mode keeps selected chip labels visible even when the current result window changes.
Selected labels stay visible while server results change.
Show sourceexamples/async-select/multiple.tsx
"use client";
import { useState } from "react";
import { AsyncSelect, FieldDescription, Stack } from "@dethink/components";
const ownerItems = [
{ label: "Ari Chen", value: "ari" },
{ label: "Mira Patel", value: "mira" },
{ label: "Noah Smith", value: "noah" },
{ label: "Sana Iqbal", value: "sana" },
];
export function AsyncSelectMultiple() {
const [query, setQuery] = useState("");
const [value, setValue] = useState<string[]>(["mira"]);
const items = ownerItems.filter((item) =>
item.label.toLocaleLowerCase().includes(query.toLocaleLowerCase()),
);
return (
<div className="mx-auto max-w-sm">
<Stack gap="3">
<AsyncSelect
selectionMode="multiple"
inputValue={query}
items={items}
label="Owners"
name="owners"
onInputValueChange={setQuery}
onValueChange={(nextValue) => setValue(nextValue as string[])}
selectedItems={ownerItems.filter((item) =>
value.includes(item.value),
)}
value={value}
/>
<FieldDescription>
Selected labels stay visible while server results change.
</FieldDescription>
</Stack>
</div>
);
}Async states
Loading, empty, error-with-retry, invalid, required, read-only, and disabled are explicit props.
Show sourceexamples/async-select/states.tsx
"use client";
import { useEffect, useState } from "react";
import { AsyncSelect } from "@dethink/components";
export function AsyncSelectStates() {
const [failed, setFailed] = useState(true);
const [retrying, setRetrying] = useState(false);
useEffect(() => {
if (!retrying) return;
const timer = setTimeout(() => {
setFailed(false);
setRetrying(false);
}, 600);
return () => clearTimeout(timer);
}, [retrying]);
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<AsyncSelect
inputValue="ac"
items={[]}
label="Loading customer"
loading
loadingMessage="Finding customers..."
/>
<AsyncSelect
inputValue="missing"
items={[]}
label="Empty customer"
emptyMessage="No customer found."
/>
<AsyncSelect
error={failed && !retrying ? "Customer lookup failed." : undefined}
loading={retrying}
inputValue="acme"
items={failed ? [] : [{ value: "acme", label: "Acme Operations" }]}
label="Errored customer"
onRetry={() => setRetrying(true)}
retryLabel="Try again"
/>
</div>
<div className="grid gap-4 md:grid-cols-3">
<AsyncSelect
errorMessage="Choose an account before saving."
invalid
items={[]}
label="Required account"
required
/>
<AsyncSelect
readOnly
defaultValue="acme"
items={[{ label: "Acme Operations", value: "acme" }]}
label="Inherited account"
/>
<AsyncSelect
disabled
defaultValue="dethink"
items={[{ label: "Dethink Labs", value: "dethink" }]}
label="Locked account"
/>
</div>
</div>
);
}Theme, RTL, and wrapping
Multiple async selection using compact dark tokens, RTL direction, and narrow chip wrapping.
Show sourceexamples/async-select/theme-and-wrapping.tsx
"use client";
import { AsyncSelect, DethinkProvider } from "@dethink/components";
const ownerItems = [
{ label: "Ari Chen", value: "ari" },
{ label: "Mira Patel", value: "mira" },
{ label: "Noah Smith", value: "noah" },
{ label: "Sana Iqbal", value: "sana" },
];
export function AsyncSelectThemeAndWrapping() {
return (
<DethinkProvider theme="dark" density="compact" dir="rtl">
<div className="border-border bg-background mx-auto max-w-72 rounded-lg border p-4">
<AsyncSelect
selectionMode="multiple"
defaultValue={["ari", "mira", "sana"] as string[]}
items={ownerItems}
label="مالكو الحساب"
name="accountOwners"
/>
</div>
</DethinkProvider>
);
}Examples that combine components for common tasks.
Server filter
A form-ready server lookup with min-query guidance and a second field using a stable result.
Show sourceexamples/async-select/recipe-server-filter.tsx
"use client";
import { useMemo, useState } from "react";
import { AsyncSelect, Form, Stack } from "@dethink/components";
const modelItems = [
{ label: "Fast summarizer", value: "fast-summarizer" },
{ label: "Reasoning planner", value: "reasoning-planner" },
{ label: "Vision analyst", value: "vision-analyst" },
{ label: "Customer support bot", value: "support-bot" },
];
export function AsyncSelectRecipeServerFilter() {
const [query, setQuery] = useState("");
const items = useMemo(
() =>
query.trim().length < 3
? []
: modelItems.filter((item) =>
item.label.toLocaleLowerCase().includes(query.toLocaleLowerCase()),
),
[query],
);
return (
<Form action="/models" method="get" className="mx-auto max-w-sm">
<Stack gap="4">
<AsyncSelect
inputValue={query}
items={items}
label="Model"
minQueryLength={3}
minQueryMessage="Type three characters before querying the server."
name="model"
onInputValueChange={setQuery}
placeholder="Search models"
/>
<AsyncSelect
defaultValue="ari"
items={[{ label: "Ari Chen", value: "ari" }]}
label="Fallback owner"
name="fallbackOwner"
/>
</Stack>
</Form>
);
}Treat the returned items as the authoritative result window, in server order.
AsyncSelect does not fetch or debounce. Cancel superseded requests or ignore their responses in your app. Keep query text separate from the selected value.
Pass selectedItems to preserve labels when a selection is outside the result window. Cached selections are not added to the available options. Without a controlled inputValue, selected labels update when the value changes or its data arrives; an active typed query is preserved.
Loading, empty, and retry feedback appears inside an open popup and below a closed field. Tab from an errored input reaches Retry; activating it returns focus to the search field. Reduced motion retains the loading text without spinning.
AsyncSelect accepts a shared item shape: { value, label?, textValue? }.
| Prop | What it does | Default |
|---|---|---|
selectionMode"single" | "multiple" | Whether the component renders Combobox or MultiSelect behavior. | "single" |
value / defaultValue / onValueChangestring | null | string[] | Controlled or uncontrolled selected value shape. | Not set |
inputValue / defaultInputValue / onInputValueChangestring / string / (text) => void | App-owned query state. AsyncSelect never fetches; your app owns the server call. | "" |
items / selectedItemsIterable<AsyncSelectItemData> | Current result window and optional selected item data for stable labels. | Not set |
loading / error / onRetryboolean / ReactNode / () => void | Explicit async status flags and retry action. | false / — / — |
emptyMessage / loadingMessage / minQueryMessageReactNode | Messages rendered in the status region. | built-in copy |
minQueryLengthnumber | Minimum query length before results should be shown. | 0 |
disabledKeysIterable<string> | Result values that cannot be selected. | Not set |
label / description / errorMessageReactNode | The field label, help text, and error message. | Not set |
namestring | Hidden input name for native forms; multiple mode submits repeated values. | Not set |