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
- Open the city list.
- Search for
m. - Select or remove a city.
- 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.
Understand the ownership
Each layer has one responsibility:
| Layer | Responsibility |
|---|---|
| React Hook Form | Stores selected IDs and field metadata. |
| "Form, Please" | Connects the field definition to the custom control. |
| TanStack Query | Loads, cancels, and caches option requests. |
| Floating UI | Positions the popup and manages its interactions. |
| Custom control | Stores 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 zod1. 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:
| Prop | Purpose |
|---|---|
queryKey | Identifies the option source and its request context. |
queryFn | Returns options for one search and supports cancellation. |
These properties control request and label behavior:
| Prop | Default | Purpose |
|---|---|---|
initialOptions | [] | Supplies labels for initial selected IDs. |
debounceMs | 250 | Delays a query after the search text changes. |
staleTime | 30_000 | Sets the option cache freshness period. |
These properties control visible text and tag count:
| Prop | Default |
|---|---|
maxVisibleTags | 3 |
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:
| State | Result |
|---|---|
| Closed | The option query is disabled. |
| Loading | The popup shows Loading… and keeps available prior data. |
| Error | The popup shows Could not load options. and Try again. |
| Empty | The popup shows the configured emptyMessage. |
| Read-only | The 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.