AsyncOptionsCombobox
Options combobox wrapper that loads option data with TanStack Query.
hardening
components/ui/async-options-combobox.tsx
Hardening
This component depends on TanStack Query context. Keep adoption narrow until loading, error, and provider expectations are finalized.
Recipe Source
"use client";import { type QueryKey, type UseQueryOptions, useQuery,} from "@tanstack/react-query";import type * as React from "react";import { useUncontrolled } from "./lib/use-uncontrolled";import { OptionsCombobox, type OptionsComboboxProps } from "./options-combobox";import type { SelectableOption } from "./selectable-option";type AsyncComboboxQueryOptions<TQueryFnData, TError, TData> = | UseQueryOptions<TQueryFnData, TError, TData, QueryKey> | (( inputValue: string, ) => UseQueryOptions<TQueryFnData, TError, TData, QueryKey>);type InputValueChangeDetails<Option extends SelectableOption> = Parameters< NonNullable<OptionsComboboxProps<Option>["onInputValueChange"]>>[1];type AsyncOptionsComboboxProps< TQueryFnData = unknown, TError = Error, TData = TQueryFnData, Option extends SelectableOption = SelectableOption,> = Omit< OptionsComboboxProps<Option>, "emptyMessage" | "onInputValueChange" | "onOpenChange" | "options"> & { defaultInputValue?: string; emptyMessage?: React.ReactNode; enabled?: boolean; errorMessage?: React.ReactNode | ((error: TError) => React.ReactNode); getOptions: (data: TData | undefined) => readonly Option[]; inputValue?: string; loadOnOpen?: boolean; loadingMessage?: React.ReactNode; minSearchLength?: number; onInputValueChange?: OptionsComboboxProps<Option>["onInputValueChange"]; onOpenChange?: (open: boolean) => void; options?: readonly Option[]; preservedOptions?: readonly Option[]; queryOptions: AsyncComboboxQueryOptions<TQueryFnData, TError, TData>;};function AsyncOptionsCombobox< TQueryFnData = unknown, TError = Error, TData = TQueryFnData, Option extends SelectableOption = SelectableOption,>({ defaultOpen = false, defaultInputValue = "", emptyMessage = "No options available", enabled = true, errorMessage = "Unable to load options", getOptions, inputValue: controlledInputValue, loadOnOpen = true, loadingMessage = "Loading options...", minSearchLength = 0, onInputValueChange, onOpenChange, open, options: fallbackOptions = [], preservedOptions = [], queryOptions, ...props}: AsyncOptionsComboboxProps<TQueryFnData, TError, TData, Option>) { const [isOpen, setIsOpen] = useUncontrolled({ value: open, defaultValue: defaultOpen, finalValue: false, onChange: onOpenChange, }); const [inputValue, setInputValue] = useUncontrolled({ value: controlledInputValue, defaultValue: defaultInputValue, finalValue: "", onChange: (nextInputValue, eventDetails) => { onInputValueChange?.( nextInputValue, eventDetails as InputValueChangeDetails<Option>, ); }, }); const resolvedQueryOptions = typeof queryOptions === "function" ? queryOptions(inputValue) : queryOptions; const shouldFetch = enabled && resolvedQueryOptions.enabled !== false && (!loadOnOpen || isOpen) && inputValue.length >= minSearchLength; const query = useQuery({ ...resolvedQueryOptions, enabled: shouldFetch, }); const fetchedOptions = query.data === undefined ? fallbackOptions : getOptions(query.data); const options = mergeOptions(fetchedOptions, preservedOptions); const resolvedEmptyMessage = (() => { if (query.isLoading || query.isFetching) { return loadingMessage; } if (query.isError) { return typeof errorMessage === "function" ? errorMessage(query.error) : errorMessage; } return emptyMessage; })(); return ( <OptionsCombobox defaultOpen={defaultOpen} inputValue={inputValue} emptyMessage={resolvedEmptyMessage} open={isOpen} onInputValueChange={setInputValue} onOpenChange={setIsOpen} options={options} {...props} /> );}function mergeOptions<Option extends SelectableOption>( options: readonly Option[], preservedOptions: readonly Option[],) { const optionMap = new Map<string, Option>(); for (const option of options) { optionMap.set(String(option.value), option); } for (const option of preservedOptions) { if (!optionMap.has(String(option.value))) { optionMap.set(String(option.value), option); } } return Array.from(optionMap.values());}export { AsyncOptionsCombobox, type AsyncOptionsComboboxProps };Usage
import { AsyncOptionsCombobox } from "@/components/recipes/async-options-combobox";
<AsyncOptionsCombobox queryOptions={queryOptions} getOptions={(data) => data ?? []} />Examples
Async Options
Use this when options should be fetched lazily as the combobox opens or search input changes.
Props
| Prop/API | Type | Default | Description |
|---|---|---|---|
queryOptions | UseQueryOptions or function | - | Query configuration used to fetch options. |
getOptions | (data) => Option[] | - | Maps query data into selectable options. |
loadOnOpen | boolean | true | Defers fetching until the combobox opens. |
minSearchLength | number | 0 | Minimum input length before fetching. |
Accessibility
- Preserve the combobox trigger and value composition.
- Provide clear loading and error messages.
- Keep query side effects in the consuming app.