react-formbridge
Browse documentation
Hooksv1.0.2

useAsyncOptions()

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
import { useState } from 'react'
import { useAsyncOptions } from '@runilib/react-formbridge'

const CITY_DB = {
  FR: ['Paris', 'Lyon', 'Marseille', 'Bordeaux', 'Lille'],
  US: ['New York', 'San Francisco', 'Chicago', 'Seattle', 'Austin'],
  GB: ['London', 'Manchester', 'Bristol', 'Leeds', 'Edinburgh'],
}

const cityFetcher = async ({ search, deps, signal }) => {
  await new Promise((resolve, reject) => {
    const timeoutId = setTimeout(resolve, 450)

    signal.addEventListener(
      'abort',
      () => {
        clearTimeout(timeoutId)
        reject(new DOMException('Aborted', 'AbortError'))
      },
      { once: true },
    )
  })

  const allCities = CITY_DB[deps.country] ?? []
  const normalized = search.trim().toLowerCase()

  return allCities
    .filter((city) => city.toLowerCase().includes(normalized))
    .map((city) => ({
      value: city.toLowerCase().replace(/\s+/g, '-'),
      label: city,
    }))
}

export function AsyncCityPlayground() {
  const [country, setCountry] = useState('FR')

  const asyncCity = useAsyncOptions({
    key: 'cities',
    fetch: cityFetcher,
    dependsOn: ['country'],
    cacheTtl: 5 * 60_000,
    debounce: 250,
    minChars: 2,
    fetchOnMount: false,
    keepPreviousOptions: true,
  }, { country })

  const canSearch = asyncCity.search.trim().length >= 2

  return (
    <div style={{ fontFamily: 'sans-serif', padding: 20, background: '#f5f7fb' }}>
      <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
        {['FR', 'US', 'GB'].map((nextCountry) => (
          <button
            key={nextCountry}
            type="button"
            onClick={() => setCountry(nextCountry)}
            style={{
              fontWeight: country === nextCountry ? 700 : 500,
            }}
          >
            {nextCountry}
          </button>
        ))}
        <button type="button" onClick={() => asyncCity.refresh()}>
          Refresh
        </button>
      </div>

      <p style={{ marginTop: 0, color: '#4b5563' }}>
        Country: <strong>{country}</strong>
      </p>

      <input
        placeholder="Type at least 2 characters"
        value={asyncCity.search}
        onChange={(e) => asyncCity.setSearch(e.target.value)}
        style={{
          width: '100%',
          maxWidth: 320,
          padding: '10px 12px',
          borderRadius: 10,
          border: '1px solid #cbd5e1',
          marginBottom: 12,
        }}
      />
      {!canSearch ? <p>Type at least 2 characters</p> : null}
      {canSearch && asyncCity.loading ? <p>Loading...</p> : null}
      {asyncCity.error ? <p>{asyncCity.error}</p> : null}
      <ul style={{ margin: 0, paddingLeft: 18 }}>
        {asyncCity.options.map((opt) => (
          <li key={opt.value}>{opt.label}</li>
        ))}
      </ul>
    </div>
  )
}

export default AsyncCityPlayground

Config

Complete AsyncOptionsConfig<TDeps> surface:

MethodTypeDescription
key?stringCache 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?numberCache lifetime in ms. Default 60_000 (1 min). 0 = no expiration (useful for static lookups)
debounce?numberDebounce in ms applied to non-empty search terms. Default 300. Empty search always fires with 0 ms delay
minChars?numberMinimum 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?booleanWhether to issue the initial fetch on mount with empty search. Default true. Set false for classic type-to-search UX
keepPreviousOptions?booleanKeep displayed options on screen while a new fetch is in flight. Default true (avoids empty flash on keystroke)
preserveOnError?booleanKeep last successful options visible when a fetch throws. Default true. Set false to collapse back to initialOptions

Minimal fetcher

Minimal.tsxtsx
1const 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
1const 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:

MethodTypeDescription
searchstringTrimmed current search term. May be empty on initial load or after clearSearch(). Never re-trim
depsTDepsSnapshot of dependency values, filtered to the keys declared in dependsOn. Values may be undefined
signalAbortSignalAbort signal wired to the current request. Automatically aborted on new fetch or unmount. Always forward it to `fetch()`

Abort handling

SafeFetcher.tsts
1fetch: 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:

MethodTypeDescription
optionsSelectOption[]Currently-displayed list. Starts as initialOptions, replaced on successful fetch, held on error unless preserveOnError: false
loadingbooleantrue between issuing a fetch and receiving its response. Stays false for cache hits
errorstring | nullerr.message from most recent throw, or 'Failed to load options.' for non-Error throws. Aborted requests do not set this
searchstringRaw search term (not trimmed). Bind to your input value prop
setSearch(next: string) => voidUpdates search and schedules a debounced fetch. Call from onChange / onChangeText
clearSearch() => voidShorthand for setSearch(''). Triggers immediate (non-debounced) refetch
refresh() => voidDeletes the current cache entry (key + search + deps) and forces a new fetch. Only invalidates this exact cache slot

Typical wiring

UseAsyncOptionsReturn.tsxtsx
1const users = useAsyncOptions({
2 key: 'users',
3 minChars: 2,
4 fetchOnMount: false,
5 fetch: fetchUsers,
6})
7
8return (
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)