AsyncOptionsCombobox

Options combobox wrapper that loads option data with TanStack Query.

hardening

Hardening

This component depends on TanStack Query context. Keep adoption narrow until loading, error, and provider expectations are finalized.

Recipe Source

components/ui/async-options-combobox.tsx
"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/APITypeDefaultDescription
queryOptionsUseQueryOptions or function-Query configuration used to fetch options.
getOptions(data) => Option[]-Maps query data into selectable options.
loadOnOpenbooleantrueDefers fetching until the combobox opens.
minSearchLengthnumber0Minimum 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.

On this page