Skip to content
Dethink Components

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.

Usage
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.

Results take 600ms. Try a name, an unmatched query, or a connection error.
Loading options...
Show sourceexamples/async-select/basic.tsx
examples/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.

Mira Patel
1 option selected

Selected labels stay visible while server results change.

Show sourceexamples/async-select/multiple.tsx
examples/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.

Finding customers...
No customer found.
Choose an account before saving.
Show sourceexamples/async-select/states.tsx
examples/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.

Ari ChenMira PatelSana Iqbal
3 options selected
Show sourceexamples/async-select/theme-and-wrapping.tsx
examples/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.

Type three characters before querying the server.
Show sourceexamples/async-select/recipe-server-filter.tsx
examples/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? }.

AsyncSelect props
PropWhat it doesDefault
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) => voidApp-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 / () => voidExplicit async status flags and retry action.false / — / —
emptyMessage / loadingMessage / minQueryMessageReactNodeMessages rendered in the status region.built-in copy
minQueryLengthnumberMinimum query length before results should be shown.0
disabledKeysIterable<string>Result values that cannot be selected.Not set
label / description / errorMessageReactNodeThe field label, help text, and error message.Not set
namestringHidden input name for native forms; multiple mode submits repeated values.Not set