RemoteSelect
remote-selectLoads debounced remote options with request cancellation, pagination, initial-value resolution, and multiple selection.
Usage
Basic usage
Enter the anti-shake search (300ms), scroll to the end and automatically add the next page, without secondary filtering locally.
<RemoteSelect
valueKey="store_id"
labelKey="store_name"
placeholder="Search store..."
fetcher={async (query, { page, pageSize, signal }) => {
const res = await fetch(`/api/stores?q=${query}&page=${page}&size=${pageSize}`, { signal })
const json = await res.json()
return { options: json.list, total: json.total }
}}
/>Initial value echo (required for editing form)
When value already exists but is not in the home screen list, use resolveValue and press id to batch solve label once; if it is not matched, only bare id will be displayed.
<RemoteSelect
valueKey="store_id"
labelKey="store_name"
defaultValue="1055"
fetcher={fetchStores}
// Separate from fetcher: it gets details by id and does not participate in search/pagination
resolveValue={async (ids) => {
const res = await fetch(`/api/stores/batch?ids=${ids.join(",")}`)
return (await res.json()).list
}}
/>Multiple choice
chip is rendered strictly in the order of value; items that are selected but not loaded can also echo the copy.
<RemoteSelect
multiple
valueKey="store_id"
labelKey="store_name"
defaultValue={["1058", "1002"]}
fetcher={fetchStores}
resolveValue={resolveStores}
/>Customized line options
renderOption can get the original row of raw and render the subtitle/number/label, etc.
<RemoteSelect
valueKey="store_id"
labelKey="store_name"
pageSize={8}
fetcher={fetchStores}
renderOption={(option) => (
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-foreground">{option.label}</span>
<span className="ml-auto shrink-0 text-xs text-muted">#{String(option.raw.store_id)}</span>
</span>
)}
/>size / disabled / invalid state
size controls the field height; disabled is grayed out overall; invalid is marked with a red border.
<>
<RemoteSelect size="sm" fetcher={fetchStores} placeholder="Search store..." />
<RemoteSelect disabled fetcher={fetchStores} placeholder="Search store..." />
<RemoteSelect invalid fetcher={fetchStores} placeholder="Search store..." />
</>When to use
Use RemoteSelect when options come from an API and the dataset is too large to load at once, such as stores, customers, products, or employees. It debounces text search, loads additional pages on scroll, and resolves selected labels when an edit form opens.
Use Select for a fixed in-memory list, Combobox for searching a consumer-provided array, or CountrySelect for its built-in country and region data. RemoteSelect builds on Combobox but delegates filtering and pagination to the server.
Import
import { RemoteSelect } from "@hulianui/ui"Props
| Name | Type | Default | Description |
|---|---|---|---|
| fetcher * | (query, { page, pageSize, signal }) => Promise<{ options, total? }> | — | Remote search source. options contains raw API rows; when supplied, total determines whether another page exists. |
| resolveValue | (values: string[]) => Promise<Row[]> | — | Batch-resolves labels for existing values. Required in edit forms; see Usage guidelines. |
| labelKey | string | "name" | Field in each raw row used as its visible label. |
| valueKey | string | "id" | Field in each raw row used as its value. |
| debounce | number | 300 | Search debounce in milliseconds. |
| pageSize | number | 10 | Page size passed to fetcher. |
| multiple | boolean | false | Enables chip-based multiple selection and changes value/onChange to arrays. |
| value | string|number|null (array when multiple) | — | Controlled value. Array order determines chip order. |
| defaultValue | Same as above | — | Initial value when uncontrolled. |
| placeholder | string | "\u8bf7\u9009\u62e9" | Field placeholder; the built-in Chinese copy means “Please select.” |
| emptyMessage | ReactNode | "\u65e0\u5339\u914d\u6570\u636e" | Empty-state content; the built-in Chinese copy means “No matching data.” |
| loadingMessage | ReactNode | "\u52a0\u8f7d\u4e2d\u2026" | Loading-state content; the built-in Chinese copy means “Loading…”. |
| size | "sm"|"md"|"lg" | "md" | Field size. |
| clearable | boolean | true | Shows a clear button for single selection; multiple selections are removed from each chip. |
| disabled | boolean | false | Disables interaction. |
| invalid | boolean | false | Applies invalid styling when used outside Field. |
| defaultOpen | boolean | false | Opens the popup initially when uncontrolled, primarily for debugging and documentation. |
| renderOption | (option) => ReactNode | — | Custom option row; option.raw is the original API row. |
| virtualized | boolean | true once 100 options have accumulated | Virtualizes the candidate list. Turn it off explicitly when renderOption draws multi-line rows — see Usage guidelines. |
| className | string | — | Class name for the field shell containing the input or chips. |
| popupClassName | string | — | Additional popup class name. |
Events
| Event | Type | Description |
|---|---|---|
| onChange | Single (value: string|null, option: Option|null) => void<br>Multiple (value: string[], options: Option[]) => void | Called when selection changes. The second argument contains complete options, including raw, in the same order as the values. |
Example
Basic usage with debounced search and scroll pagination:
<RemoteSelect
valueKey="store_id"
labelKey="store_name"
placeholder="Search store…"
fetcher={async (query, { page, pageSize, signal }) => {
const res = await fetch(`/api/stores?q=${query}&page=${page}&size=${pageSize}`, { signal })
const json = await res.json()
return { options: json.list, total: json.total }
}}
/>Edit-form label hydration when the value is not on the first page:
<RemoteSelect
valueKey="store_id"
labelKey="store_name"
value={form.storeId}
onChange={(v) => setForm({ ...form, storeId: v })}
fetcher={fetchStores}
// Separate endpoint: resolves rows by ID and does not participate in search pagination
resolveValue={async (ids) => (await api.storesByIds(ids)).list}
/>Multiple selection:
<RemoteSelect
multiple
valueKey="store_id"
labelKey="store_name"
value={form.storeIds}
onChange={(ids) => setForm({ ...form, storeIds: ids })}
fetcher={fetchStores}
resolveValue={resolveStores}
/>Usage guidelines
- The list virtualizes automatically once 100 candidates have accumulated — remote paging appends page by page, so this switches on after enough pages. Only visible options stay in the DOM, and row height is estimated at a fixed 32px without per-item measurement. The default single-line label is exactly 32px, so nothing changes for it. If
renderOptiondraws multi-line rows or avatars, placement starts drifting somewhere past the tenth page — nothing throws, and the first pages never reproduce it. Passvirtualized={false}for that usage. - `resolveValue` is required in edit forms. An existing
valuemay be on a later page or excluded by the current query. OnlyresolveValuecan obtain its label; without it, the field displays the raw ID. Keep it separate fromfetcher: one batch-fetches rows by primary key, while the other searches pages by keyword. - Forward `signal` from `fetcher` to fetch or Axios. The component discards stale responses by request sequence even without cancellation, but uncancelled requests still occupy connections during rapid typing.
- Render multiple-selection chips in `value` order.
ComboboxChipRemoveassociates each chip withselectedValue[index]; reordering custom output can remove the wrong item. The built-in rendering already preserves value order. - There is no second local filter because the underlying Combobox uses
filter={null}. Results are entirely server-defined; iffetcherignoresquery, typing does not search. - Closing the popup ends the search session: the query is cleared and the first page loads again on the next open, matching remote
el-selectbehavior. Keepfetcherfree of side effects beyond idempotent caching for identical parameters. - Pagination works without
totalby assuming a page with at leastpageSizerows may have a successor. This can cause one final empty request, so returntotalwhen the API provides it.
Related
Combobox · Select · CountrySelect · Cascader · ProTable
Playground
<RemoteSelect
valueKey="store_id"
labelKey="store_name"
size="md"
placeholder="Search store..."
clearable={true}
fetcher={fetchStores}
resolveValue={resolveStores}
/>