Async search
Fetch suggestions with debouncing, cancellation, loading, empty, failure, and same-query retry.
Pass a fetcher as items: (query, signal) => Promise<readonly TItem[]>. The fetcher supplies the search results; Mention does not apply the array filter to its response.
Try failure and recovery
Type @al. This local example delays its response and deliberately fails the first completed search. Choose Retry search to repeat the same query without editing your message, then select Alice. Search @nobody to see an empty result.
Type @ to search Alice or Bob. The first search simulates a failure; retry to search again without editing your message.
The status message stays outside the listbox and announces loading, the result count, an empty response, or a failure. A failed search hides the popup and removes its suggestion relationships from the input. The recovery button remains available when focus leaves the editor.
Use the complete example
The running example, this source block, and the browser fixture use the same component. Replace the local delay, sample filtering, and deliberate failure with your application's search request; keep the cancellation and recovery behavior.
"use client";
import {
Mention,
type MentionImperativeHandle,
useMentionContext,
} from "@danielivanov/mention";
import { useCallback, useId, useRef, useState } from "react";
import "@danielivanov/mention/styles.css";
type Person = { id: string; name: string };
const people: Person[] = [
{ id: "alice", name: "Alice" },
{ id: "bob", name: "Bob" },
];
function SearchFeedback({
id,
failure,
onRetry,
}: {
id: string;
failure: boolean;
onRetry: () => void;
}) {
const { status, items } = useMentionContext<Person>();
const loading = status === "loading";
return (
<div>
<p id={id} role="status" aria-atomic="true">
{loading
? "Searching people…"
: failure
? "Could not load people. Try again."
: status === "success"
? items.length > 0
? `${items.length} ${items.length === 1 ? "person" : "people"} found. Use the arrow keys to choose.`
: "No people found. Try another name."
: ""}
</p>
{failure && !loading && (
<button type="button" onClick={onRetry}>
Retry search
</button>
)}
</div>
);
}
export function AsyncSearch() {
const id = useId();
const inputRef = useRef<HTMLTextAreaElement>(null);
const mentionRef = useRef<MentionImperativeHandle<Person>>(null);
const failNext = useRef(true);
// Keep recovery available when moving focus from the editor to Retry.
const [failedSearch, setFailedSearch] = useState<{
text: string;
caret: number;
} | null>(null);
const searchPeople = useCallback(
async (query: string, signal: AbortSignal) => {
setFailedSearch(null);
const input = inputRef.current;
const snapshot = input
? { text: input.value, caret: input.selectionStart }
: null;
try {
// Local demo only: delay the response and fail the first completed request.
// Replace this block with your search endpoint, forwarding its signal.
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, 400);
signal.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(signal.reason);
},
{ once: true },
);
});
signal.throwIfAborted();
if (failNext.current) {
failNext.current = false;
throw new Error("Simulated search failure");
}
return people.filter((person) =>
person.name.toLowerCase().includes(query.toLowerCase()),
);
} catch (error) {
if (!signal.aborted) setFailedSearch(snapshot);
throw error;
}
},
[],
);
return (
<Mention.Root<Person>
items={searchPeople}
getKey={(person) => person.id}
getLabel={(person) => person.name}
handleRef={mentionRef}
>
<label htmlFor={id}>Message</label>
<p id={`${id}-hint`}>
Type @ to search Alice or Bob. The first search simulates a failure;
retry to search again without editing your message.
</p>
<Mention.Input
ref={inputRef}
id={id}
rows={4}
aria-describedby={`${id}-hint ${id}-status`}
onChange={() => setFailedSearch(null)}
onSelect={(event) => {
const input = event.currentTarget;
setFailedSearch((previous) =>
previous?.text === input.value &&
previous.caret === input.selectionStart &&
input.selectionStart === input.selectionEnd
? previous
: null,
);
}}
/>
<Mention.Popover container={null} aria-label="People">
<Mention.List<Person>>
{(person) => (
<Mention.Item value={person}>{person.name}</Mention.Item>
)}
</Mention.List>
<Mention.Loading aria-hidden="true">Searching people…</Mention.Loading>
<Mention.Empty aria-hidden="true">
No people found. Try another name.
</Mention.Empty>
</Mention.Popover>
<SearchFeedback
id={`${id}-status`}
failure={failedSearch !== null}
onRetry={() => {
inputRef.current?.focus();
mentionRef.current?.open();
}}
/>
</Mention.Root>
);
}
useMentionContext<Person>() reads the existing Root session. A standalone useMention() call creates a separate session and cannot read that Root's status.
Connect a search endpoint
Forward the supplied signal to fetch, check the response status, and validate the returned data before using it. For an endpoint returning { id: string, name: string }[], replace the demo search block with:
const response = await fetch(`/api/people?q=${encodeURIComponent(query)}`, {
signal,
});
if (!response.ok) throw new Error(`Search failed: ${response.status}`);
const data: unknown = await response.json();
if (
!Array.isArray(data) ||
!data.every(
(person) =>
typeof person === "object" && person !== null &&
typeof person.id === "string" && typeof person.name === "string",
)
) {
throw new Error("Unexpected search response");
}
return data as Person[];Implement this endpoint in your application or replace its URL and validation with your API's contract. The running example makes no network requests.
Set the debounce delay
Async requests debounce for 150 ms by default. Set debounceMs={300} on Root to wait longer, or debounceMs={0} to start immediately. This delay applies only to fetchers, not synchronous item arrays.
Cancel unnecessary work
When the query, channel, or fetcher changes, or the session closes, Mention aborts the previous request. Forwarding the signal lets the network client stop work that is no longer needed.
The core also discards late results when a fetcher ignores cancellation. It hides previous results as soon as the query changes and will not commit an obsolete item. If the fetcher updates application feedback, check signal.aborted before recording its failure too, as the example does.
Keep the fetcher reference stable when its behavior has not changed: declare it outside the component, or use useCallback with its real dependencies. A new fetcher reference starts a new request.
Retry without editing
Call the handle's open() or the hook's setOpen(true) to retry a failed search at the current caret. It creates a new request session, leaving the text unchanged. Calling it during loading or after a successful search does not duplicate that request. Ordinary refresh() calls only rescan the editor; they do not retry an unchanged failed query.
The example retains its failure message across blur because moving to Retry search dismisses the editing session. The button restores input focus before calling open(), so the response is immediately ready for arrow-key selection. Changing the text or selection clears the old failure: recovery feedback belongs to that text and caret, too. The library supplies no automatic retries, cache, or retry control.
Show each outcome
- Keep one persistent
role="status"region outside the listbox. Update its text for loading, result count, no matches, and failure; verify actual announcements with your target screen readers. Mention.Loadingrenders while a request is pending, including its debounce delay.Mention.Emptyrenders after a successful query with no results.status === "error"identifies a rejected active request. Its popup is hidden, while the failed session remains available for explicit retry.- Put interactive recovery controls outside the listbox, which is reserved for suggestions.
Return [] for no matches. Throw for request, response, or parsing failures. These are different outcomes and should have different feedback.
Synchronous arrays may be created during render. The core derives their filtered results directly; fresh array references are not prohibited. Memoize expensive data preparation when your application benefits from it.
Verify request ordering
Make one query slow and a later query fast. Confirm that the fast query's results remain current when the earlier request finally resolves, including when a test fetcher deliberately ignores the signal. Also check a failed request, same-query retry by pointer and keyboard, a successful empty response, and editing while a retry is pending.