Experimental low-level hook for remote option lists.
- In v1, prefer
field.select().optionsFrom(...)as the stable public path for async option loading - Use
useAsyncOptions()directly only when you need a fully custom async autocomplete or picker UI - The hook handles debounce, caching, cancellation, dependency keys, and refreshes for you
- Because it is experimental, the low-level contract may still evolve before it is treated as stable
Config
Complete AsyncOptionsConfig<TDeps> surface:
| Method | Type | Description |
|---|---|---|
key? | string | Cache namespace. Default 'default'. Combined with trimmed search + deps fingerprint to form <key>::<search>::<depsFingerprint>. Process-wide module-level cache. Pick a distinct key per resource |
fetch | (context) => Promise<SelectOption[]> | Required async fetcher. Must return SelectOption[] ({ label, value, ...extras }). Non-arrays coerced to []. Throwing sets error and (unless preserveOnError: false) keeps the previous options |
cacheTtl? | number | Cache lifetime in ms. Default 60_000 (1 min). 0 = no expiration (useful for static lookups) |
debounce? | number | Debounce in ms applied to non-empty search terms. Default 300. Empty search always fires with 0 ms delay |
minChars? | number | Minimum non-empty search length before a fetch fires. Default 0. Below the threshold, options snap back to initialOptions |
dependsOn? | readonly (keyof TDeps)[] | Ordered list of keys from depValues that matter. Drives: filtered deps in fetch(), cache key composition, and refetch on change |
initialOptions? | SelectOption[] | Options shown before first fetch, when search < minChars, and (when preserveOnError: false) after an error. Default [] |
fetchOnMount? | boolean | Whether to issue the initial fetch on mount with empty search. Default true. Set false for classic type-to-search UX |
keepPreviousOptions? | boolean | Keep displayed options on screen while a new fetch is in flight. Default true (avoids empty flash on keystroke) |
preserveOnError? | boolean | Keep last successful options visible when a fetch throws. Default true. Set false to collapse back to initialOptions |
Minimal fetcher
Minimal.tsxtsx
| 1 | const tags = useAsyncOptions({ |
| 2 | key: 'tags', |
| 3 | fetch: async ({ search, signal }) => { |
| 4 | const res = await fetch('/api/tags?q=' + encodeURIComponent(search), { signal }) |
| 5 | const data: { id: string; name: string }[] = await res.json() |
| 6 | return data.map((tag) => ({ value: tag.id, label: tag.name })) |
| 7 | }, |
| 8 | }) |
Dependency-aware fetcher (city depends on country)
CityDependsOnCountry.tsxtsx
| 1 | const cities = useAsyncOptions( |
| 2 | { |
| 3 | key: 'cities', |
| 4 | dependsOn: ['country'], |
| 5 | minChars: 2, |
| 6 | debounce: 250, |
| 7 | cacheTtl: 5 * 60_000, |
| 8 | fetchOnMount: false, |
| 9 | fetch: async ({ search, deps, signal }) => { |
| 10 | const url = '/api/cities?country=' + deps.country + '&q=' + encodeURIComponent(search) |
| 11 | const res = await fetch(url, { signal }) |
| 12 | const data: { id: string; name: string }[] = await res.json() |
| 13 | return data.map((c) => ({ value: c.id, label: c.name })) |
| 14 | }, |
| 15 | }, |
| 16 | { country }, |
| 17 | ) |
OptionsFetcherContext<TDeps> - the single argument passed to your fetch() callback:
| Method | Type | Description |
|---|---|---|
search | string | Trimmed current search term. May be empty on initial load or after clearSearch(). Never re-trim |
deps | TDeps | Snapshot of dependency values, filtered to the keys declared in dependsOn. Values may be undefined |
signal | AbortSignal | Abort signal wired to the current request. Automatically aborted on new fetch or unmount. Always forward it to `fetch()` |
Abort handling
SafeFetcher.tsts
| 1 | fetch: async ({ search, deps, signal }) => { |
| 2 | const res = await fetch('/api/users?q=' + encodeURIComponent(search), { signal }) |
| 3 | // fetch() rejects with AbortError when signal is aborted - the hook |
| 4 | // detects it and quietly drops the result, so no try/catch needed here. |
| 5 | if (!res.ok) { |
| 6 | throw new Error('Failed to load users (HTTP ' + res.status + ')') |
| 7 | } |
| 8 | return (await res.json()).map((u) => ({ value: u.id, label: u.name })) |
| 9 | }, |
key + search + deps compose the internal cache key.
Return
Complete UseAsyncOptionsReturn surface:
| Method | Type | Description |
|---|---|---|
options | SelectOption[] | Currently-displayed list. Starts as initialOptions, replaced on successful fetch, held on error unless preserveOnError: false |
loading | boolean | true between issuing a fetch and receiving its response. Stays false for cache hits |
error | string | null | err.message from most recent throw, or 'Failed to load options.' for non-Error throws. Aborted requests do not set this |
search | string | Raw search term (not trimmed). Bind to your input value prop |
setSearch | (next: string) => void | Updates search and schedules a debounced fetch. Call from onChange / onChangeText |
clearSearch | () => void | Shorthand for setSearch(''). Triggers immediate (non-debounced) refetch |
refresh | () => void | Deletes the current cache entry (key + search + deps) and forces a new fetch. Only invalidates this exact cache slot |
Typical wiring
UseAsyncOptionsReturn.tsxtsx
| 1 | const users = useAsyncOptions({ |
| 2 | key: 'users', |
| 3 | minChars: 2, |
| 4 | fetchOnMount: false, |
| 5 | fetch: fetchUsers, |
| 6 | }) |
| 7 | |
| 8 | return ( |
| 9 | <div> |
| 10 | <input |
| 11 | value={users.search} |
| 12 | onChange={(e) => users.setSearch(e.target.value)} |
| 13 | placeholder="Search users…" |
| 14 | /> |
| 15 | |
| 16 | {users.search && <button type='button' onClick={users.clearSearch}>Clear</button>} |
| 17 | <button type='button' onClick={users.refresh}>Refresh</button> |
| 18 | |
| 19 | {users.loading && <Spinner />} |
| 20 | {users.error && <p role='alert'>{users.error}</p>} |
| 21 | |
| 22 | <ul> |
| 23 | {users.options.map((opt) => ( |
| 24 | <li key={opt.value}>{opt.label}</li> |
| 25 | ))} |
| 26 | </ul> |
| 27 | </div> |
| 28 | ) |