Autocomplete: Loading, empty and error states
When the Autocomplete has no options to show, its list shows one status row instead: loading, empty, an error with a retry button, or a prompt to type more. Each comes from a prop you pass, and each row is a slot.
Loading
Select…
Empty
Select…
Error
Select…
Min. characters
Select…
import { useId, useState, type ReactNode } from "react";
import { Autocomplete, fold } from "slotsmith/autocomplete";
import { countries, type Country } from "../shared/options";
const byCode = { getOptionValue: (entry: Country) => entry.code, getOptionLabel: (entry: Country) => entry.name };
/** A visible label, and the id that names the picker after it. */
function Field({ label, children }: { label: string; children: (labelId: string) => ReactNode }) {
const id = useId();
return (
<div>
<p id={id}>{label}</p>
{children(id)}
</div>
);
}
export default function States() {
const [failed, setFailed] = useState(true);
const [results, setResults] = useState<Country[]>([]);
return (
<div style={{ display: "grid", gap: "1rem", gridTemplateColumns: "repeat(2, minmax(0, 1fr))" }}>
<Field label="Loading">
{/* A first page is in flight and there is nothing to show yet. */}
{(id) => <Autocomplete options={[]} loading aria-labelledby={id} />}
</Field>
<Field label="Empty">
{(id) => <Autocomplete options={[]} labels={{ empty: "No countries match" }} aria-labelledby={id} />}
</Field>
<Field label="Error">
{/* The error replaces the list; onRetry adds the retry button. */}
{(id) => (
<Autocomplete<Country>
{...byCode}
options={failed ? [] : countries}
error={failed ? "503 Service Unavailable" : undefined}
onRetry={() => setFailed(false)}
aria-labelledby={id}
/>
)}
</Field>
<Field label="Min. characters">
{/* Nothing is searched until three characters are typed. */}
{(id) => (
<Autocomplete<Country>
{...byCode}
options={results}
filter={false}
minChars={3}
onSearchChange={(query) => {
const text = query.trim();
setResults(text.length >= 3 ? countries.filter((entry) => fold(entry.name).includes(fold(text))) : []);
}}
aria-labelledby={id}
/>
)}
</Field>
</div>
);
}export type Country = { code: string; name: string };
/** Sorted by name, as a picker shows them. */
export const countries: Country[] = [
{ code: "ar", name: "Argentina" },
{ code: "au", name: "Australia" },
{ code: "br", name: "Brazil" },
{ code: "ca", name: "Canada" },
{ code: "eg", name: "Egypt" },
{ code: "fr", name: "France" },
{ code: "de", name: "Germany" },
{ code: "in", name: "India" },
{ code: "jp", name: "Japan" },
{ code: "jo", name: "Jordan" },
{ code: "ke", name: "Kenya" },
{ code: "mx", name: "Mexico" },
{ code: "ma", name: "Morocco" },
{ code: "nl", name: "Netherlands" },
{ code: "no", name: "Norway" },
{ code: "pt", name: "Portugal" },
{ code: "sa", name: "Saudi Arabia" },
{ code: "za", name: "South Africa" },
{ code: "es", name: "Spain" },
{ code: "se", name: "Sweden" },
{ code: "tr", name: "Türkiye" },
{ code: "ae", name: "United Arab Emirates" },
{ code: "gb", name: "United Kingdom" },
{ code: "us", name: "United States" },
];
/** `id` and `label` are the names the autocomplete reads by default, so topics need no getters. */
export type Topic = { id: string; label: string; archived?: boolean };
export const topics: Topic[] = [
{ id: "accessibility", label: "Accessibility" },
{ id: "animation", label: "Animation" },
{ id: "caching", label: "Caching" },
{ id: "design-systems", label: "Design systems" },
{ id: "forms", label: "Forms" },
{ id: "i18n", label: "Internationalisation" },
{ id: "performance", label: "Performance" },
{ id: "rtl", label: "Right-to-left" },
{ id: "server-components", label: "Server components" },
{ id: "state", label: "State" },
{ id: "testing", label: "Testing" },
{ id: "typescript", label: "TypeScript" },
{ id: "legacy", label: "Legacy browsers", archived: true },
];
/** A country with the region it is listed under; a `disabled` one stays in the list but cannot be picked. */
export type RegionCountry = Country & { region: string; disabled?: boolean };
/** A short list grouped by region. Groups show in the order of their first option, so the regions come in this order. */
export const countriesByRegion: RegionCountry[] = [
{ code: "de", name: "Germany", region: "Europe" },
{ code: "fr", name: "France", region: "Europe" },
{ code: "es", name: "Spain", region: "Europe" },
{ code: "ge", name: "Georgia", region: "Europe" },
{ code: "dz", name: "Algeria", region: "Africa" },
{ code: "eg", name: "Egypt", region: "Africa" },
{ code: "ng", name: "Nigeria", region: "Africa" },
{ code: "ne", name: "Niger", region: "Africa", disabled: true },
{ code: "jp", name: "Japan", region: "Asia" },
{ code: "sg", name: "Singapore", region: "Asia" },
{ code: "sa", name: "Saudi Arabia", region: "Asia" },
{ code: "sy", name: "Syria", region: "Asia" },
];Open each picker to see its row. Retry in the error picker clears the error and shows the options.
Which row shows
One row shows at a time, in this order:
- Error, while
erroris set. It replaces the list entirely, so a failed request never reads as “No results”. WithonRetryit has a retry button named by theretrylabel. - Loading, while
loadingis true and there are no options to show. With options on screen, the list keeps them and setsaria-busy="true"instead. The trigger shows a spinner wheneverloadingis true. - Minimum characters, while fewer than
minCharscharacters are typed and there are no options, from theminCharslabel. - Empty, when the search matches nothing, from the
emptylabel.
The live region stays silent while a status row shows; it reads the result count again once options are back.
Changing the rows
- The text: pass
labels, as the demo does withempty: "No countries match". - The row itself: replace the
Empty,LoadingorErrorslot.Emptyreceives the query and the status, so one component can word “no results” and “type more” differently. - In your own layout,
Autocomplete.StatusRowsrenders the right row for you.
The creatable guide shows the row that replaces “No results” when new options can be made.