Autocomplete: Remote options
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.
- No requests yet. Open the picker.
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>
);
}import type { OptionsPage } from "slotsmith/autocomplete";
/** One package of the made-up registry the remote options guide searches. */
export type Pkg = { id: string; name: string; group: string; downloads: number };
const SCOPES = ["core", "ui", "forms", "data", "router", "motion", "intl", "icons", "charts", "auth", "query", "testing"];
const SUFFIXES = ["kit", "primitives", "hooks", "engine", "runtime", "adapter", "preset", "bridge", "stream", "schema", "store", "devtools"];
/** 144 packages, built from their position so every load returns the same ones. */
export const PACKAGES: Pkg[] = SCOPES.flatMap((scope, scopeIndex) =>
SUFFIXES.map((suffix, suffixIndex) => ({
id: `${scope}-${suffix}`,
name: `@acme/${scope}-${suffix}`,
group: scope,
downloads: 1200 + scopeIndex * 977 + suffixIndex * 313,
})),
);
const PAGE_SIZE = 12;
/** Waits `ms`, and rejects with an `AbortError` as soon as the signal aborts. */
function wait(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const cancelled = () => new DOMException("The request was cancelled.", "AbortError");
if (signal.aborted) return reject(cancelled());
const timer = setTimeout(() => {
signal.removeEventListener("abort", abort);
resolve();
}, ms);
const abort = () => {
clearTimeout(timer);
reject(cancelled());
};
signal.addEventListener("abort", abort, { once: true });
});
}
/**
* A stand-in for a paged search endpoint: it matches names containing the
* query, answers one page of 12 after `latency` milliseconds (250 by
* default), and stops as soon as `signal` aborts. `nextPage` is set while
* more matches exist.
*/
export async function searchPackages(
query: string,
page: number,
signal: AbortSignal,
{ latency = 250 }: { latency?: number } = {},
): Promise<OptionsPage<Pkg>> {
await wait(latency, signal);
const needle = query.trim().toLowerCase();
const matches = needle ? PACKAGES.filter((pkg) => pkg.name.includes(needle)) : PACKAGES;
const end = page * PAGE_SIZE;
return { data: matches.slice(end - PAGE_SIZE, end), nextPage: end < matches.length ? page + 1 : undefined };
}
/** `12400` → `12.4k`. */
export const formatDownloads = (downloads: number) => (downloads >= 1000 ? `${(downloads / 1000).toFixed(1)}k` : String(downloads));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 totrue.
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 theloadingrow. - 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
loadthrows, the list shows the error with a retry button; see the 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, loading, error and onRetry, hasMore and onLoadMore from the query library you already use, set filter to false, and fetch in 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 slot: it receives the option record and its label.