# Autocomplete: Remote options

> Load the slotsmith Autocomplete's options from a server with useAsyncOptions: debounced, cancelled when superseded, and paged.

The Autocomplete never fetches: it renders the options it is given. For options from a server, the `useAsyncOptions` hook calls your `load` function as the search changes, debounced and cancellable, and returns the props to spread onto `Autocomplete`.

```tsx
import { useRef, useState, type CSSProperties } from "react";
import { Autocomplete, useAsyncOptions, type AutocompleteOptionLabelSlotProps, type OptionValue } from "slotsmith/autocomplete";
import { formatDownloads, searchPackages, type Pkg } from "../shared/fake-catalog";

type Request = { id: number; url: string; status: "pending" | "done" | "cancelled" };

/** A richer option row: the record goes to the OptionLabel slot. */
function PackageRow({ option, label }: AutocompleteOptionLabelSlotProps<Pkg>) {
  return (
    <span style={{ display: "flex", justifyContent: "space-between", gap: "1rem", inlineSize: "100%" }}>
      <span dir="ltr">{label}</span>
      <span dir="ltr" style={{ opacity: 0.7 }}>
        {formatDownloads(option.downloads)}
      </span>
    </span>
  );
}

// One line per request, cut short with an ellipsis on a narrow screen.
const clip: CSSProperties = { overflow: "hidden", textOverflow: "ellipsis" };

export default function RemoteOptions() {
  const [open, setOpen] = useState(false);
  const [slow, setSlow] = useState(false);
  const [packageId, setPackageId] = useState<OptionValue | null>(null);
  const [log, setLog] = useState<Request[]>([]);
  const nextId = useRef(0);

  // Only a request still pending can end as cancelled: the next search aborts the last signal even when its answer has arrived.
  const track = (id: number, status: Request["status"]) =>
    setLog((all) => all.map((entry) => (entry.id === id && entry.status === "pending" ? { ...entry, status } : entry)));

  // `reload` is not an Autocomplete prop: take it out, then spread the rest.
  const { reload, ...packages } = useAsyncOptions<Pkg>({
    // Nothing is fetched until the picker opens.
    enabled: open,
    debounce: 300,
    load: async (query, page, signal) => {
      const id = ++nextId.current;
      setLog((all) => [{ id, url: `GET /packages?q=${encodeURIComponent(query)}&page=${page}`, status: "pending" as const }, ...all].slice(0, 5));
      // A newer search aborts this one; pass the signal to fetch() in a real app.
      signal.addEventListener("abort", () => track(id, "cancelled"));
      const result = await searchPackages(query, page, signal, { latency: slow ? 2000 : 250 });
      track(id, "done");
      return result;
    },
  });

  return (
    <div style={{ display: "grid", gap: "1rem", maxWidth: 420 }}>
      <label>
        <input type="checkbox" checked={slow} onChange={(event) => setSlow(event.target.checked)} /> Slow network (2 s a request)
      </label>
      {/* The log sits above the picker, where the open list cannot cover it, with room kept for all five lines so the picker never moves. */}
      <div>
        <button type="button" onClick={reload} style={{ background: "transparent" }}>
          Reload
        </button>
        <ul
          dir="ltr"
          style={{ margin: "0.5rem 0 0", paddingInlineStart: "1.25rem", fontFamily: "monospace", fontSize: "0.8125rem", lineHeight: 1.5, minBlockSize: "7.5em", whiteSpace: "nowrap" }}
        >
          {log.length === 0 ? <li>No requests yet. Open the picker.</li> : log.map((entry) => <li key={entry.id} style={clip}>{`${entry.url} · ${entry.status}`}</li>)}
        </ul>
      </div>
      <Autocomplete<Pkg>
        {...packages}
        getOptionLabel={(pkg) => pkg.name}
        open={open}
        onOpenChange={setOpen}
        value={packageId}
        onChange={setPackageId}
        components={{ OptionLabel: PackageRow }}
        aria-label="Package"
      />
    </div>
  );
}
```

The demo searches a made-up registry of 144 packages, 12 a page. Each request it makes is listed above the picker, where the open list does not cover it. Tick "Slow network" and type quickly: a request that a newer search replaces is marked `cancelled`.

## The load function

The demo's code tab shows the whole picker. `load(query, page, signal)` returns a promise of `{ data, nextPage }`: one page of options, and the next page number while more exist. Pages count from 1. Pass `signal` on to `fetch`, so a superseded request stops.

What the hook takes:

- `debounce`: milliseconds to wait after typing stops. Defaults to 300.
- `minChars`: no request until the query is this long. Defaults to 0.
- `enabled`: pass the popup's open state, as the demo does, so nothing is requested until the picker opens. Without it, every picker on a page requests as soon as it mounts.
- `keepPreviousData`: keep the current options on screen while the next search loads. Defaults to `true`.

It returns `options`, `loading`, `loadingMore`, `error`, `hasMore`, `minChars`, `onSearchChange`, `onLoadMore`, `onRetry` and `filter: false` (the server has already filtered), all `Autocomplete` props, plus `reload`, which runs the current search again from page one. `reload` is not a prop: take it out before spreading, as above.

## Slow responses and stale results

- While a new search loads, the previous results stay on screen; the list has `aria-busy="true"` and the trigger shows a spinner. Only when there is nothing to show yet does the list show the [`loading`](https://slotsmith.dev/components/autocomplete/api/#label-loading) row.
- Each search aborts the request before it through `signal`, and a response that arrives after a newer search started is dropped, so the list never shows an earlier query's results.
- When `load` throws, the list shows the error with a retry button; see [the states](https://slotsmith.dev/components/autocomplete/guides/states/).

## Paging

While `hasMore` is true, the list ends with a "Load more" row. It loads the next page when it scrolls into view inside the list, or when it is pressed. The next page is appended, and `loadingMore` shows a spinner in that row meanwhile.

## Without the hook

`Autocomplete` takes the same props from anywhere: pass [`options`](https://slotsmith.dev/components/autocomplete/api/#prop-options), [`loading`](https://slotsmith.dev/components/autocomplete/api/#prop-loading), [`error`](https://slotsmith.dev/components/autocomplete/api/#prop-error) and [`onRetry`](https://slotsmith.dev/components/autocomplete/api/#prop-onRetry), [`hasMore`](https://slotsmith.dev/components/autocomplete/api/#prop-hasMore) and [`onLoadMore`](https://slotsmith.dev/components/autocomplete/api/#prop-onLoadMore) from the query library you already use, set [`filter`](https://slotsmith.dev/components/autocomplete/api/#prop-filter) to `false`, and fetch in [`onSearchChange`](https://slotsmith.dev/components/autocomplete/api/#prop-onSearchChange).

## Richer rows

An option's label is a string. To show more in each row, as the demo does with download counts, replace the [`OptionLabel`](https://slotsmith.dev/components/autocomplete/api/#slot-OptionLabel) slot: it receives the `option` record and its `label`.
