# Async Options

Load search results, cancel stale requests, and fetch the next page.

`useAsyncOptions` loads items as someone types. It waits for typing to pause, cancels requests that are replaced, and keeps the previous results visible while loading.

```vue title="async.vue"
<script setup lang="ts">
import { CheckIcon, ChevronDownIcon } from "@lucide/vue"
import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxItemIndicator,
  ComboboxLabel,
  ComboboxList,
  ComboboxStatus,
  ComboboxTrigger,
} from "@ui-sdk/vue/combobox"
import { useAsyncOptions } from "@ui-sdk/vue/async-options"

const movieTitles = [
  "The Shawshank Redemption",
  "The Godfather",
  "The Dark Knight",
  "Pulp Fiction",
  "Schindler's List",
  "The Lord of the Rings",
  "Fight Club",
  "Forrest Gump",
  "Inception",
  "The Matrix",
  "Goodfellas",
  "Interstellar",
  "Parasite",
  "Spirited Away",
  "Whiplash",
  "The Prestige",
  "The Departed",
  "Gladiator",
  "Alien",
  "Casablanca",
  "Amélie",
  "Arrival",
]

function searchMovies(query: string, { signal }: { signal: AbortSignal }) {
  return new Promise<{ value: string; label: string }[]>((resolve, reject) => {
    const timer = setTimeout(() => {
      const needle = query.trim().toLowerCase()
      resolve(
        movieTitles
          .filter((title) => title.toLowerCase().includes(needle))
          .map((title) => ({
            value: title.toLowerCase().replace(/\W+/g, "-"),
            label: title,
          }))
      )
    }, 500)
    signal.addEventListener("abort", () => {
      clearTimeout(timer)
      reject(signal.reason)
    })
  })
}

const { state: movies, setQuery } = useAsyncOptions(searchMovies, {
  debounce: 300,
  query: "the",
  immediate: true,
})
</script>

<template>
  <Combobox
    :items="movies.items"
    default-query="the"
    :filter="false"
    :status="movies.status"
    @query-change="(query: string) => setQuery(query)"
  >
    <div data-slot="stack">
      <ComboboxLabel>Movie</ComboboxLabel>
      <div data-slot="row">
        <ComboboxInput placeholder="Search movies…" />
        <ComboboxTrigger aria-label="Show movies">
          <ChevronDownIcon aria-hidden="true" />
        </ComboboxTrigger>
      </div>
    </div>
    <ComboboxContent>
      <ComboboxStatus v-slot="{ state }">
        {{
          state.status === "error"
            ? "Couldn't load movies."
            : state.loading
              ? "Searching…"
              : `${state.count} ${state.count === 1 ? "movie" : "movies"}`
        }}
      </ComboboxStatus>
      <ComboboxEmpty v-slot="{ state }">
        <template v-if="state.empty && !state.loading"
          >No movies match “{{ state.query }}”.</template
        >
      </ComboboxEmpty>
      <ComboboxList v-slot="{ state }">
        <ComboboxItem
          v-for="item in state.items as {
            value: string
            label: string
            disabled?: boolean
          }[]"
          :key="item.value"
          :value="item.value"
          :label="item.label"
          :disabled="item.disabled"
        >
          {{ item.label }}
          <ComboboxItemIndicator>
            <CheckIcon aria-hidden="true" />
          </ComboboxItemIndicator>
        </ComboboxItem>
      </ComboboxList>
    </ComboboxContent>
  </Combobox>
</template>
```

Change the movie search and wait for the results. Use the arrow keys to choose a movie, then press Enter.

## Load Results

Pass a loader to the helper. This example expects `/api/movies` to return an array of movie titles:

```vue
<script setup lang="ts">
import { useAsyncOptions } from "@ui-sdk/vue/async-options"
import { Combobox, ComboboxContent, ComboboxInput } from "@ui-sdk/vue/combobox"

const movies = useAsyncOptions<string>(async (query, { signal }) => {
  const response = await fetch(`/api/movies?q=${encodeURIComponent(query)}`, {
    signal,
  })
  if (!response.ok) throw new Error("Couldn't load movies")
  return response.json()
})
const { state } = movies
</script>

<template>
  <Combobox
    :items="state.items"
    :filter="false"
    :status="state.status"
    @query-change="movies.setQuery"
  >
    <ComboboxInput aria-label="Movie" placeholder="Search movies…" />
    <ComboboxContent />
  </Combobox>
</template>
```

`state` is a ref, unwrapped in the template. In script, read `state.value.items` or `state.value.status`.

Pass `signal` to `fetch` so a newer search can cancel the request. The helper cancels on unmount. `filter` set to false leaves matching and ordering to the server.

Use `ComboboxStatus` and `ComboboxEmpty` to show loading, error, and empty messages, as in the demo. The same pattern works with [Select](/docs/vue/components/select#async-search), [Autocomplete](/docs/vue/components/autocomplete#async-suggestions), and [Command](/docs/vue/components/command#async-results).

## Choose When to Load

The second argument sets the starting behavior:

| Option      | What It Does                                            | Default |
| ----------- | ------------------------------------------------------- | ------- |
| `debounce`  | Milliseconds typing must pause before a request starts. | `200`   |
| `query`     | The starting search text.                               | `""`    |
| `immediate` | Loads that text as soon as the component mounts.        | `false` |

For initial choices, add `{ immediate: true, debounce: 300 }` after the loader. Until that first load starts, `status` is already `"loading"`, so the list never flashes an empty state. If you set `query`, use the same starting text for the component's `default-query`.

## Load More

For paged results, the loader receives `page`, starting at `0`. Return `{ items, hasMore }`:

```ts
import type { AsyncOptionsContext } from "@ui-sdk/vue/async-options"

async function loadMoviePages(
  query: string,
  { signal, page }: AsyncOptionsContext
) {
  const response = await fetch(
    `/api/movies?q=${encodeURIComponent(query)}&page=${page}`,
    { signal }
  )
  if (!response.ok) throw new Error("Couldn't load movies")
  const data: { titles: string[]; next: number | null } = await response.json()
  return { items: data.titles, hasMore: data.next !== null }
}

export { loadMoviePages }
```

Pass `loadMoviePages` to `useAsyncOptions` in place of the inline loader above.

Connect `@load-more="movies.loadMore"` on Combobox. It asks for another page when scrolling or keyboard navigation reaches the last item. The helper appends the new items.

```vue title="infinite.vue"
<script setup lang="ts">
import { CheckIcon, ChevronDownIcon } from "@lucide/vue"
import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxItemIndicator,
  ComboboxLabel,
  ComboboxList,
  ComboboxStatus,
  ComboboxTrigger,
} from "@ui-sdk/vue/combobox"
import { useAsyncOptions } from "@ui-sdk/vue/async-options"

const movieTitles = [
  "The Shawshank Redemption",
  "The Godfather",
  "The Dark Knight",
  "Pulp Fiction",
  "Schindler's List",
  "The Lord of the Rings",
  "Fight Club",
  "Forrest Gump",
  "Inception",
  "The Matrix",
  "Goodfellas",
  "Interstellar",
  "Parasite",
  "Spirited Away",
  "Whiplash",
  "The Prestige",
  "The Departed",
  "Gladiator",
  "Alien",
  "Casablanca",
  "Amélie",
  "Arrival",
]

function searchMoviePages(
  query: string,
  { signal, page }: { signal: AbortSignal; page: number }
) {
  return new Promise<{
    items: { value: string; label: string }[]
    hasMore: boolean
  }>((resolve, reject) => {
    const timer = setTimeout(() => {
      const needle = query.trim().toLowerCase()
      const found = movieTitles
        .filter((title) => title.toLowerCase().includes(needle))
        .map((title) => ({
          value: title.toLowerCase().replace(/\W+/g, "-"),
          label: title,
        }))
      resolve({
        items: found.slice(page * 5, page * 5 + 5),
        hasMore: found.length > page * 5 + 5,
      })
    }, 500)
    signal.addEventListener("abort", () => {
      clearTimeout(timer)
      reject(signal.reason)
    })
  })
}

const {
  state: movies,
  setQuery,
  loadMore,
} = useAsyncOptions(searchMoviePages, { immediate: true })
</script>

<template>
  <Combobox
    :items="movies.items"
    :filter="false"
    :status="movies.status"
    @query-change="(query: string) => setQuery(query)"
    @load-more="loadMore"
  >
    <div data-slot="stack">
      <ComboboxLabel>Movie</ComboboxLabel>
      <div data-slot="row">
        <ComboboxInput placeholder="Search movies…" />
        <ComboboxTrigger aria-label="Show movies">
          <ChevronDownIcon aria-hidden="true" />
        </ComboboxTrigger>
      </div>
    </div>
    <ComboboxContent>
      <ComboboxEmpty v-slot="{ state }">
        <template v-if="state.empty && !state.loading"
          >No movies found.</template
        >
      </ComboboxEmpty>
      <ComboboxList v-slot="{ state }">
        <ComboboxItem
          v-for="item in state.items as {
            value: string
            label: string
            disabled?: boolean
          }[]"
          :key="item.value"
          :value="item.value"
          :label="item.label"
          :disabled="item.disabled"
        >
          {{ item.label }}
          <ComboboxItemIndicator>
            <CheckIcon aria-hidden="true" />
          </ComboboxItemIndicator>
        </ComboboxItem>
      </ComboboxList>
      <ComboboxStatus v-slot="{ state }">
        {{
          state.loading
            ? "Loading more…"
            : movies.hasMore
              ? `${state.count} loaded`
              : `All ${state.count}`
        }}
      </ComboboxStatus>
    </ComboboxContent>
  </Combobox>
</template>
```

Open the movie list and scroll down, or use Arrow Down to reach the last result. The next page is added to the list.

## Retry a Search

`status` is `"loading"` during a request, `"error"` after a failure, and `"idle"` otherwise. `error` holds the failure, and the previous items stay available.

Call `movies.reload()` from a retry button to load the current query again, starting at page `0`. `state.value.hasMore` tells you whether another page is available.
