Skip to content

AsyncAutocomplete

Form

Remote-style search with cancellation, grouped results, and clear loading, empty, and error states.

Grouped people searchDebounced asynchronous resultsLoading / empty / retryDisabled choices
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

Usage

Use this controlled input to select a record from asynchronous results. Supply an AutocompleteOption or null, onValueChange, and a stable loadOptions(query, signal) callback. Requests are debounced and aborted when the query changes, the input closes, or the component unmounts. Results from adapters that ignore abort are still discarded. Selection changes only after choosing a result or pressing Clear; leaving uncommitted text restores the controlled selection. Result values are deduplicated. The example uses a delayed local directory; the Users page uses the active data provider. A real API adapter must enforce authorization server-side.

Import
import {
  AsyncAutocomplete,
  type AutocompleteOption,
} from "@/components/ui/async-autocomplete";

Shipped as editable source in src/components/ui/async-autocomplete.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.

Component source

The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.

src/components/ui/async-autocomplete.tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";
import { Search, LoaderCircle, X } from "lucide-react";
import { Input } from "@dashboardpack/core/components/ui/input";
import { Label } from "@dashboardpack/core/components/ui/label";
import { cn } from "@dashboardpack/core/lib/utils";

export interface AutocompleteOption {
  value: string;
  label: string;
  description?: string;
  group?: string;
  disabled?: boolean;
}
export interface AsyncAutocompleteProps {
  label: string;
  loadOptions: (
    query: string,
    signal: AbortSignal,
  ) => Promise<AutocompleteOption[]>;
  value: AutocompleteOption | null;
  onValueChange: (option: AutocompleteOption | null) => void;
  placeholder?: string;
  minLength?: number;
  debounceMs?: number;
  disabled?: boolean;
  className?: string;
}
export function AsyncAutocomplete({
  label,
  loadOptions,
  value,
  onValueChange,
  placeholder = "Search…",
  minLength = 2,
  debounceMs = 250,
  disabled,
  className,
}: AsyncAutocompleteProps) {
  const id = useId();
  const input = useRef<HTMLInputElement>(null);
  const [query, setQuery] = useState(value?.label ?? "");
  const [editing, setEditing] = useState(false);
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(-1);
  const [retry, setRetry] = useState(0);
  const [result, setResult] = useState<{
    query: string;
    options: AutocompleteOption[];
    status: "ready" | "loading" | "error";
  }>({ query: "", options: [], status: "ready" });
  const search = query.trim();
  const eligible = search.length >= Math.max(0, minLength);
  const options =
    result.query === search && result.status === "ready" ? result.options : [];
  const status = result.query === search ? result.status : "loading";
  const displayed = editing ? query : (value?.label ?? "");
  useEffect(() => {
    if (!open || !editing || !eligible || disabled) return;
    const controller = new AbortController();
    const timer = setTimeout(
      async () => {
        setResult({ query: search, options: [], status: "loading" });
        try {
          const data = await loadOptions(search, controller.signal);
          if (!controller.signal.aborted) {
            const seen = new Set<string>();
            setResult({
              query: search,
              options: data.filter((item) => {
                if (seen.has(item.value)) return false;
                seen.add(item.value);
                return true;
              }),
              status: "ready",
            });
            setActive(-1);
          }
        } catch {
          if (!controller.signal.aborted)
            setResult({ query: search, options: [], status: "error" });
        }
      },
      Math.max(0, debounceMs),
    );
    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [
    search,
    eligible,
    open,
    editing,
    disabled,
    loadOptions,
    debounceMs,
    retry,
  ]);
  const choose = (option: AutocompleteOption) => {
    if (option.disabled) return;
    onValueChange(option);
    setEditing(false);
    setOpen(false);
    setActive(-1);
  };
  return (
    <div
      className={cn("relative min-w-0 space-y-2", className)}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget)) {
          setOpen(false);
          setEditing(false);
          setActive(-1);
        }
      }}
    >
      <Label htmlFor={id}>{label}</Label>
      <div className="relative">
        <Search
          aria-hidden
          className="pointer-events-none absolute start-3 top-3 size-4 text-muted-foreground"
        />
        <Input
          ref={input}
          id={id}
          role="combobox"
          autoComplete="off"
          aria-autocomplete="list"
          aria-expanded={open && !disabled}
          aria-controls={`${id}-list`}
          aria-describedby={`${id}-status`}
          aria-activedescendant={
            open && options[active] ? `${id}-option-${active}` : undefined
          }
          placeholder={placeholder}
          disabled={disabled}
          value={displayed}
          className="ps-9 pe-10"
          onFocus={() => {
            setQuery(value?.label ?? "");
            setOpen(true);
          }}
          onChange={(event) => {
            setQuery(event.target.value);
            setEditing(true);
            setActive(-1);
            setOpen(true);
          }}
          onKeyDown={(event) => {
            if (event.nativeEvent.isComposing) return;
            if (event.key === "Escape") {
              event.preventDefault();
              setOpen(false);
              setEditing(false);
              setActive(-1);
            }
            if (event.key === "ArrowDown" || event.key === "ArrowUp") {
              event.preventDefault();
              setOpen(true);
              const enabled = options
                .map((item, index) => (item.disabled ? -1 : index))
                .filter((index) => index >= 0);
              if (!enabled.length) return;
              const currentIndex = enabled.indexOf(active);
              const next =
                currentIndex < 0
                  ? event.key === "ArrowDown"
                    ? 0
                    : enabled.length - 1
                  : (currentIndex +
                      (event.key === "ArrowDown" ? 1 : -1) +
                      enabled.length) %
                    enabled.length;
              setActive(enabled[next]);
              document
                .getElementById(`${id}-option-${enabled[next]}`)
                ?.scrollIntoView({ block: "nearest" });
            }
            if (event.key === "Enter" && open && options[active]) {
              event.preventDefault();
              choose(options[active]);
            }
          }}
        />
        {value && !disabled && (
          <button
            type="button"
            aria-label={`Clear ${label}`}
            className="absolute end-1 top-1 rounded-md p-2 text-muted-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            onClick={() => {
              onValueChange(null);
              setQuery("");
              setEditing(true);
              setOpen(true);
              setActive(-1);
              input.current?.focus();
            }}
          >
            <X className="size-4" />
          </button>
        )}
      </div>
      <p
        id={`${id}-status`}
        role="status"
        className="text-xs text-muted-foreground"
      >
        {open && editing && eligible
          ? status === "loading"
            ? "Searching…"
            : status === "error"
              ? "Search failed. Try again."
              : `${options.length} results available.`
          : value
            ? `Selected: ${value.label}`
            : `Type at least ${Math.max(0, minLength)} characters to search.`}
      </p>
      {open && !disabled && (
        <div className="absolute inset-x-0 top-full z-30 mt-1 overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg">
          <ul
            id={`${id}-list`}
            role="listbox"
            aria-label={`${label} results`}
            aria-busy={editing && eligible && status === "loading"}
            className="max-h-64 overflow-auto p-1"
          >
            {editing &&
              eligible &&
              options.map((option, index) => (
                <li
                  key={option.value}
                  role="option"
                  id={`${id}-option-${index}`}
                  aria-selected={value?.value === option.value}
                  aria-disabled={option.disabled}
                  className={cn(
                    "cursor-pointer rounded-lg px-3 py-2 text-sm outline-none",
                    active === index && "bg-accent text-accent-foreground",
                    option.disabled && "cursor-not-allowed opacity-50",
                  )}
                  onPointerMove={() => {
                    if (!option.disabled) setActive(index);
                  }}
                  onMouseDown={(event) => event.preventDefault()}
                  onClick={() => choose(option)}
                >
                  {option.group &&
                    (index === 0 ||
                      options[index - 1].group !== option.group) && (
                      <span className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
                        {option.group}
                      </span>
                    )}
                  <span className="block font-medium">{option.label}</span>
                  {option.description && (
                    <span className="block text-xs text-muted-foreground">
                      {option.description}
                    </span>
                  )}
                </li>
              ))}
          </ul>
          {!editing || !eligible ? (
            <p className="px-4 pb-3 text-sm text-muted-foreground">
              Search people, projects, or records.
            </p>
          ) : status === "loading" ? (
            <p className="flex items-center gap-2 px-4 pb-3 text-sm text-muted-foreground">
              <LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" />
              Looking for matches
            </p>
          ) : status === "error" ? (
            <div className="space-y-2 px-4 pb-3 text-sm">
              <p>Could not load results.</p>
              <button
                type="button"
                className="rounded text-primary underline focus-visible:ring-2 focus-visible:ring-ring"
                onMouseDown={(event) => event.preventDefault()}
                onClick={() => setRetry((count) => count + 1)}
              >
                Retry search
              </button>
            </div>
          ) : (
            !options.length && (
              <p className="px-4 pb-3 text-sm text-muted-foreground">
                No matches. Try a different search.
              </p>
            )
          )}
        </div>
      )}
    </div>
  );
}

API reference

Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.

AsyncAutocomplete props
PropType / DefaultDescription
labelstring

Default: required

Persistent visible input label.
loadOptions(query, signal) => Promise<AutocompleteOption[]>

Default: required

Stable async loader. Return unique values, labels, and optional descriptions, groups, and disabled state. Honor the abort signal where supported.
value / onValueChangeAutocompleteOption | null / callback

Default: required

Parent-owned committed selection. Null clears it.
minLength / debounceMsnumber / number

Default: 2 / 250

Minimum trimmed search length and delay before requesting results.
placeholder / disabled / classNamestring / boolean / string

Default: Search… / false / —

Input hint, disabled state, and container styling.

Build with these too