Skip to content
Form, Please

Async multiselect

This example builds one searchable field that stores a string[] value. TanStack Query loads the options. Floating UI supplies the popup interactions.

Use this pattern when the option set is remote or too large to load in full. For a short static list, use a simpler local control without TanStack Query.

Try the example

  1. Open the city list.
  2. Search for m.
  3. Select or remove a city.
  4. Select Save selection to see the submitted IDs.

The list stays open when you change a selection. Repeated searches use the TanStack Query cache.

Live demo

Open the list and search for “m”. Repeated searches reuse the query cache.

Search the remote list and keep more than one city.

TokyoIstanbulMoscow+1
Submit to see the validated city IDs.

Understand the ownership

Each layer has one responsibility:

LayerResponsibility
React Hook FormStores selected IDs and field metadata.
"Form, Please"Connects the field definition to the custom control.
TanStack QueryLoads, cancels, and caches option requests.
Floating UIPositions the popup and manages its interactions.
Custom controlStores search text, labels, and popup state.

The form value contains IDs, not { value, label } objects. A label change does not change the form value or make the form dirty.

Install the dependencies

This example uses Zod as its Standard Schema library. It uses Heroicons for the four interface icons.

npm install form-please react-hook-form @tanstack/react-query @floating-ui/react @heroicons/react zod

1. Model one array field

Define one array value in the schema. Supply the initial IDs through defaultValues.

const initiallySelected = [
	{ value: "tokyo", label: "Tokyo" },
	{ value: "istanbul", label: "Istanbul" },
	{ value: "moscow", label: "Moscow" },
	{ value: "mumbai", label: "Mumbai" },
] satisfies readonly AsyncMultiSelectOption[]
 
const schema = z.object({
	cityIds: z.array(z.string()).min(1, "Choose at least one city"),
})
 
const defaultValues = {
	cityIds: initiallySelected.map((city) => city.value),
} satisfies FormInput<typeof schema>

Use a field node for this value. Do not use an array UI node. An array node renders repeatable rows, but this control updates one string[] value.

2. Define and register the control

The control props separate request settings from display text:

export type AsyncMultiSelectOption = {
	readonly value: string
	readonly label: string
	readonly disabled?: boolean
}
 
export type AsyncMultiSelectProps = {
	readonly queryKey: readonly unknown[]
	readonly queryFn: (
		search: string,
		signal: AbortSignal,
	) => Promise<readonly AsyncMultiSelectOption[]>
	readonly initialOptions?: readonly AsyncMultiSelectOption[]
	readonly debounceMs?: number
	readonly staleTime?: number
	readonly maxVisibleTags?: number
	readonly placeholder?: string
	readonly searchPlaceholder?: string
	readonly emptyMessage?: string
	readonly dialogLabel?: string
}

Register the control in the same kit that defines the form:

const kit = createFormKit({
	controls: {
		...createNativeControls(),
		asyncMultiSelect,
	},
	slots: createDefaultSlots(),
})

The defineControl call binds the control to string[]. The form definition can use asyncMultiSelect only for a compatible field path.

3. Load and validate API options

Pass the TanStack Query AbortSignal to fetch. Check the HTTP status and validate the response before you return options:

import {  } from "zod"
 
import type {  } from "./async-multiselect"
 
const  = .(
	.({
		: .(),
		: .(),
		: .().(),
	}),
)
 
export async function (
	: string,
	: AbortSignal,
): <readonly []> {
	const  = new ({ :  })
	const  = await (`/api/cities?${.()}`, {  })
 
	if (!.) {
		throw new (`City search failed with status ${.}`)
	}
 
	return .(await .())
}

The request rejects when the server returns an error status or invalid data. The control shows a retry action for the rejected query.

The live demo uses a local array and an abortable delay. This data source keeps the documentation site deterministic:

async function searchCities(
	search: string,
	signal: AbortSignal,
): Promise<readonly AsyncMultiSelectOption[]> {
	await abortableDelay(450, signal)
	const normalizedSearch = normalizeSearch(search)
 
	return cities.filter((city) =>
		normalizeSearch(city.label).includes(normalizedSearch),
	)
}

Replace the demo query with the API request in application code.

4. Connect search to TanStack Query

The control appends the debounced search text to the supplied query key:

	const optionsQuery = useQuery({
		queryKey: [...multiSelectProps.queryKey, debouncedSearch],
		queryFn: ({ signal }) => multiSelectProps.queryFn(debouncedSearch, signal),
		enabled: open,
		placeholderData: keepPreviousData,
		staleTime: multiSelectProps.staleTime ?? 30_000,
	})

The query starts only while the popup is open. keepPreviousData keeps the previous result available while the next request loads.

Add every value that changes the response to the base queryKey. For example, use queryKey: ["cities", tenantId, locale] when the tenant and locale change the option list.

The generic async field options API loads a complete list. This custom control owns remote search instead, because query text, previous results, retries, and loading UI are part of its interaction. Pass the asynchronous function through the control props as queryFn.

5. Configure the field

Add the field to the form definition:

const definition = kit.defineForm(schema, {
	ui: [
		{
			kind: "field",
			path: "cityIds",
			control: "asyncMultiSelect",
			label: "Cities",
			description: "Search the remote list and keep more than one city.",
			required: true,
			props: {
				queryKey: ["cities"],
				queryFn: searchCities,
				initialOptions: initiallySelected,
				placeholder: "Choose cities",
				searchPlaceholder: "Search cities",
				emptyMessage: "No cities match this search.",
				dialogLabel: "City options",
			},
		},
	],
})

The control requires these request props:

PropPurpose
queryKeyIdentifies the option source and its request context.
queryFnReturns options for one search and supports cancellation.

These properties control request and label behavior:

PropDefaultPurpose
initialOptions[]Supplies labels for initial selected IDs.
debounceMs250Delays a query after the search text changes.
staleTime30_000Sets the option cache freshness period.

These properties control visible text and tag count:

PropDefault
maxVisibleTags3
placeholder"Choose options"
searchPlaceholder"Search options"
emptyMessage"No options found."
dialogLabel"Options"

Preserve labels for selected IDs

A new search can replace the current option result. The control keeps every loaded option in a local label cache:

	const optionCache = useRef(
		new Map(
			(multiSelectProps.initialOptions ?? []).map(
				(option) => [option.value, option] as const,
			),
		),
	)
 
	const optionsQuery = useQuery({
		queryKey: [...multiSelectProps.queryKey, debouncedSearch],
		queryFn: ({ signal }) => multiSelectProps.queryFn(debouncedSearch, signal),
		enabled: open,
		placeholderData: keepPreviousData,
		staleTime: multiSelectProps.staleTime ?? 30_000,
	})
 
	useEffect(() => {
		for (const option of multiSelectProps.initialOptions ?? []) {
			optionCache.current.set(option.value, option)
		}
	}, [multiSelectProps.initialOptions])
 
	useEffect(() => {
		for (const option of optionsQuery.data ?? []) {
			optionCache.current.set(option.value, option)
		}
	}, [optionsQuery.data])

For an edit form, load the selected records with the form baseline. Pass those records through initialOptions. If a label is not available, the control shows the ID until it loads that option.

The cache stores labels for display only. Submission still uses the ID array.

Provide TanStack Query and submit the IDs

Reuse the application QueryClientProvider when one already exists. The live example creates one client for its own component:

export function AsyncMultiSelectExample() {
	const [queryClient] = useState(
		() =>
			new QueryClient({
				defaultOptions: {
					queries: {
						retry: false,
					},
				},
			}),
	)
	const [savedCityIds, setSavedCityIds] = useState<readonly string[]>()
	const form = kit.useForm(definition, {
		defaultValues,
		onSubmit: ({ value }) => setSavedCityIds(value.cityIds),
	})
	let output = "Submit to see the validated city IDs."
	if (savedCityIds !== undefined) {
		output = `Saved: ${savedCityIds.join(", ")}`
	}
 
	return (
		<QueryClientProvider client={queryClient}>
			<section
				aria-label="Async multiselect example"
				className="form-please-complex form-please-async-demo"
				data-testid="async-multiselect-demo"
			>
				<p className="form-please-async-demo__kicker">Live demo</p>
				<p className="form-please-async-demo__summary">
					Open the list and search for “m”. Repeated searches reuse the query
					cache.
				</p>
				<kit.AutoForm form={form}>
					<kit.Submit>Save selection</kit.Submit>
				</kit.AutoForm>
				<output aria-live="polite" data-testid="async-multiselect-output">
					{output}
				</output>
			</section>
		</QueryClientProvider>
	)
}

setValue replaces the complete array when a selection changes. React Hook Form stores that array as input. The Standard Schema validates it during submit.

The submit callback receives the validated cityIds. The server must confirm that each submitted ID exists and that the user can select it.

Handle interaction and failure states

The control has these states:

StateResult
ClosedThe option query is disabled.
LoadingThe popup shows Loading… and keeps available prior data.
ErrorThe popup shows Could not load options. and Try again.
EmptyThe popup shows the configured emptyMessage.
Read-onlyThe user can inspect options but cannot change the search or value.

Floating UI supplies click, dismiss, focus-return, positioning, and list navigation behavior. The control handles ID toggling and keeps the popup open. It also forwards the field ID, description relation, invalid state, disabled state, read-only state, and focus ref from ControlProps.

Copy the control implementation

The file below contains the control and the live demo wiring. Its CSS class names have no library styles. Add application styles for those class names, or replace them with your design-system components.

Copy async-multiselect.tsx
"use client"
 
import {
	,
	,
	,
	,
	,
	,
	,
	,
	,
	,
	,
	,
} from "@floating-ui/react"
import {
	,
	,
	,
	,
} from "@heroicons/react/20/solid"
import {
	,
	,
	,
	,
} from "@tanstack/react-query"
import {
	type ,
	,
	,
	type ,
} from "form-please"
import {  } from "form-please/default-slots"
import {  } from "form-please/native-controls"
import { , , ,  } from "react"
import {  } from "zod"
 
export type  = {
	readonly : string
	readonly : string
	readonly ?: boolean
}
 
export type  = {
	readonly : readonly unknown[]
	readonly : (
		: string,
		: AbortSignal,
	) => <readonly []>
	readonly ?: readonly []
	readonly ?: number
	readonly ?: number
	readonly ?: number
	readonly ?: string
	readonly ?: string
	readonly ?: string
	readonly ?: string
}
 
const : readonly [] = []
 
function ({
	,
	,
	,
	,
	,
	: ,
	,
	,
	,
}: <string[], >) {
	const [, ] = (false)
	const [, ] = ("")
	const [, ] = <number | null>(null)
	const  = (
		,
		. ?? 250,
	)
	const  = <HTMLInputElement>(null)
	const  = <<HTMLElement | null>>([])
	const  = (
		new (
			(. ?? []).(
				() => [., ] as ,
			),
		),
	)
 
	const  = ({
		: [...., ],
		: ({  }) => .(, ),
		: ,
		: ,
		: . ?? 30_000,
	})
 
	(() => {
		for (const  of . ?? []) {
			..(., )
		}
	}, [.])
 
	(() => {
		for (const  of . ?? []) {
			..(., )
		}
	}, [.])
 
	const  = ()
	const  = (
		() =>
			(. ?? ).(() =>
				(.).(),
			),
		[, .],
	)
	const  = .(
		() =>
			..() ?? {
				: ,
				: ,
			},
	)
	const  = . ?? 3
	const  = .(0, )
	const  = . - .
	const  = `${.}-listbox`
	const { , ,  } = ({
		,
		: ,
		: "bottom-start",
		: [(8), ({ : 8 }), ({ : 8 })],
		: ,
	})
	const  = (, { : ! })
	const  = ()
	const  = (, { : "dialog" })
	const  = (, {
		,
		,
		: ,
		: true,
		: false,
		: .((, ) => {
			if (.) return []
			return []
		}),
	})
	const { , ,  } = (
		[, , , ],
	)
	const  = ([., .])
	let  = "selections"
	if ( === 1)  = "selection"
	let : string | undefined
	if ()  = 
	let  = "Open options"
	if ()  = "Close options"
 
	(() => {
		(null)
		.. = .
	}, [.])
 
	function (: boolean) {
		()
		if (!) {
			("")
			(null)
			()
		}
	}
 
	function (: ) {
		if ( || .) return
 
		if (.(.)) {
			(.(() =>  !== .))
			return
		}
 
		([..., .])
	}
 
	return (
		<
			="async-multiselect"
			={ || }
			={. || }
			={ || }
		>
			<
				="async-multiselect__trigger"
				={.}
			>
				< ="async-multiselect__tags">
					{.(() => (
						< ="async-multiselect__tag" ={.}>
							<>{.}</>
							<
								={`Remove ${.}`}
								={ || }
								={() =>
									(
										.(
											() =>  !== .,
										),
									)
								}
								="button"
							>
								<
									="true"
									="async-multiselect__small-icon"
								/>
							</>
						</>
					))}
					{ > 0 && (
						< ="async-multiselect__tag async-multiselect__tag--count">
							<>+{}</>
							<
								={`Remove ${} hidden ${}`}
								={ || }
								={() => (.(0, ))}
								="button"
							>
								<
									="true"
									="async-multiselect__small-icon"
								/>
							</>
						</>
					)}
					{. === 0 && (
						< ="async-multiselect__placeholder">
							{. ?? "Choose options"}
						</>
					)}
				</>
 
				<
					="Clear selection"
					="async-multiselect__icon-button"
					={ ||  || . === 0}
					={() => ([])}
					="button"
				>
					<
						="true"
						="async-multiselect__small-icon"
					/>
				</>
				< ="true" ="async-multiselect__separator" />
				<
					={}
					={["aria-describedby"]}
					={}
					="listbox"
					={. || }
					={}
					="async-multiselect__icon-button"
					={}
					={.}
					={}
					="button"
					{...()}
				>
					<
						="true"
						="async-multiselect__small-icon"
					/>
				</>
			</>
 
			{ && (
				<
					={}
					={}
					={false}
					
				>
					<
						={. ?? "Options"}
						="async-multiselect__dropdown"
						={.}
						="dialog"
						={}
						{...()}
					>
						< ="async-multiselect__search-row">
							<
								="true"
								="async-multiselect__search-icon"
							/>
							<
								="list"
								={}
								="true"
								={
									. ?? "Search options"
								}
								={ || }
								="off"
								="async-multiselect__search-input"
								={() => {
									(..)
									(null)
								}}
								={
									. ?? "Search options"
								}
								={}
								={}
								="combobox"
								="search"
								={}
							/>
							{. && (
								< ="polite" ="async-multiselect__status">
									Loading…
								</>
							)}
						</>
 
						<
							={. || }
							="true"
							="async-multiselect__options"
							={}
							="listbox"
						>
							{. && (
								< ="async-multiselect__message" ="alert">
									<>Could not load multiSelectProps.</>
									< ={() => .()} ="button">
										Try again
									</>
								</>
							)}
							{!. &&
								!. &&
								. === 0 && (
									< ="async-multiselect__message" ="status">
										{. ?? "No options found."}
									</>
								)}
							{.((, ) => {
								const  = .(.)
								let  = -1
								if ( === )  = 0
 
								return (
									<
										={. || }
										={}
										="async-multiselect__option"
										={ === ( ?? 0) || }
										={`${}-option-${}`}
										={.}
										={() => {
											.[] = 
										}}
										="option"
										={}
										{...({
											: () => (),
											: () => {
												if (. === "Enter" || . === " ") {
													.()
													()
												}
											},
										})}
									>
										<
											="true"
											="async-multiselect__check"
											={ || }
										>
											{ && (
												< ="async-multiselect__check-icon" />
											)}
										</>
										<>{.}</>
									</>
								)
							})}
						</>
 
						< ="async-multiselect__footer">
							<
								={ || . === 0}
								={() => ([])}
								="button"
							>
								Clear
							</>
							< ={() => (false)} ="button">
								Close
							</>
						</>
					</>
				</>
			)}
		</>
	)
}
 
export const  = <string[], >({
	: ,
})
 
function <>(: , : number):  {
	const [, ] = ()
 
	(() => {
		const  = .(() => (), )
		return () => .()
	}, [, ])
 
	return 
}
 
function (: string): string {
	return .().()
}
 
const  = [
	{ : "tokyo", : "Tokyo" },
	{ : "istanbul", : "Istanbul" },
	{ : "moscow", : "Moscow" },
	{ : "mumbai", : "Mumbai" },
] satisfies readonly []
 
const  = .({
	: .(.()).(1, "Choose at least one city"),
})
 
const  = {
	: .(() => .),
} satisfies <typeof >
 
const  = [
	...,
	{ : "rome", : "Rome" },
	{ : "berlin", : "Berlin" },
	{ : "lisbon", : "Lisbon" },
	{ : "paris", : "Paris" },
] satisfies readonly []
 
async function (
	: string,
	: AbortSignal,
): <readonly []> {
	await (450, )
	const  = ()
 
	return .(() =>
		(.).(),
	)
}
 
const  = ({
	: {
		...(),
		,
	},
	: (),
})
 
const  = .(, {
	: [
		{
			: "field",
			: "cityIds",
			: "asyncMultiSelect",
			: "Cities",
			: "Search the remote list and keep more than one city.",
			: true,
			: {
				: ["cities"],
				: ,
				: ,
				: "Choose cities",
				: "Search cities",
				: "No cities match this search.",
				: "City options",
			},
		},
	],
})
 
export function () {
	const [] = (
		() =>
			new ({
				: {
					: {
						: false,
					},
				},
			}),
	)
	const [, ] = <readonly string[]>()
	const  = .(, {
		,
		: ({  }) => (.),
	})
	let  = "Submit to see the validated city IDs."
	if ( !== ) {
		 = `Saved: ${.(", ")}`
	}
 
	return (
		< ={}>
			<
				="Async multiselect example"
				="form-please-complex form-please-async-demo"
				="async-multiselect-demo"
			>
				< ="form-please-async-demo__kicker">Live demo</>
				< ="form-please-async-demo__summary">
					Open the list and search for “m”. Repeated searches reuse the query
					cache.
				</>
				<. ={}>
					<.>Save selection</.>
				</.>
				< ="polite" ="async-multiselect-output">
					{}
				</>
			</>
		</>
	)
}
 
function (: number, : AbortSignal): <void> {
	if (.) {
		return .(new ("Aborted", "AbortError"))
	}
 
	return new ((, ) => {
		function () {
			.()
			(new ("Aborted", "AbortError"))
		}
 
		const  = .(() => {
			.("abort", )
			()
		}, )
 
		.("abort", , { : true })
	})
}

Before production use, confirm these requirements:

  • Include all request context in queryKey.
  • Validate the option response at the API boundary.
  • Supply labels for initial selected IDs.
  • Test loading, error, empty, disabled, and read-only states.
  • Validate submitted IDs and authorization on the server.

Read Form kits for the control contract. Read Validation and submission for the submit sequence. Read Styling for application-owned control styles.