Controlled forms
Validate, submit, and reset a real form while Mention preserves ordinary textarea behavior.
Your form owns the text. Mention writes selections through the input's normal change handler, so typed text and inserted mentions follow the same state and submission path.
Try the form
Submit an empty message to see validation. Then type @al, select Alice, and submit again. The receipt shows the actual FormData value. Reset clears the text, validation, and receipt, and returns focus to the input.
This example keeps submissions on the page. In your application, send the validated form data through your existing submission handler.
Use the complete example
The running form, this source block, and the browser test fixture use the same component. No form library or Mention-specific form wrapper is needed. Style the native form controls with your application's design system.
"use client";
import { Mention, type MentionImperativeHandle } from "@danielivanov/mention";
import { type FormEvent, useId, useRef, useState } from "react";
import { flushSync } from "react-dom";
import "@danielivanov/mention/styles.css";
type Person = { id: string; name: string };
const people: Person[] = [
{ id: "alice", name: "Alice" },
{ id: "bob", name: "Bob" },
];
export function MessageForm() {
const id = useId();
const inputRef = useRef<HTMLTextAreaElement>(null);
const mentionRef = useRef<MentionImperativeHandle<Person>>(null);
const [value, setValue] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitted, setSubmitted] = useState<string | null>(null);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!value.trim()) {
setError("Enter a message before submitting.");
setSubmitted(null);
inputRef.current?.focus();
return;
}
setError(null);
const data = new FormData(event.currentTarget);
setSubmitted(String(data.get("message")));
}
function reset(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
flushSync(() => {
setValue("");
setError(null);
setSubmitted(null);
});
inputRef.current?.focus();
mentionRef.current?.close();
}
return (
<form
aria-label="Message form"
noValidate
onSubmit={submit}
onReset={reset}
>
<Mention.Root<Person>
items={people}
getKey={(person) => person.id}
getLabel={(person) => person.name}
handleRef={mentionRef}
>
<label htmlFor={id}>Message</label>
<p id={`${id}-hint`}>
Type @ to mention Alice or Bob. A message is required.
</p>
<Mention.Input
ref={inputRef}
id={id}
name="message"
required
rows={4}
value={value}
onChange={(event) => {
const next = event.currentTarget.value;
setValue(next);
setSubmitted(null);
if (next.trim()) setError(null);
}}
aria-invalid={error !== null}
aria-describedby={`${id}-hint${error ? ` ${id}-error` : ""}`}
/>
{error && (
<p id={`${id}-error`} role="alert">
{error}
</p>
)}
<Mention.Popover container={null} aria-label="People">
<Mention.List<Person>>
{(person) => (
<Mention.Item value={person}>{person.name}</Mention.Item>
)}
</Mention.List>
<Mention.Empty>No people found. Try another name.</Mention.Empty>
</Mention.Popover>
</Mention.Root>
<div>
<button type="submit">Submit message</button>
<button type="reset">Reset</button>
</div>
<p role="status" aria-atomic="true">
{submitted !== null
? `Submitted locally: ${submitted}`
: "This demo keeps submissions on this page."}
</p>
</form>
);
}
Validation and focus
The form uses noValidate to display its own required-field error. It checks trimmed text on submission, connects the error to the textarea with aria-describedby, sets aria-invalid, and focuses the invalid field through its ordinary React ref. Correcting the value clears the error.
Enter commits a highlighted suggestion instead of submitting the form. Outside suggestion selection, Enter retains its normal multiline-input behavior. The submit button follows the usual form path.
With a form library, pass its value, change handler, blur handler, name, and ref to Mention.Input. Do not treat Root's onSelect callback as a replacement for the field's change handler: it only reports completed mention insertions.
Reset controlled state
Reset updates React state explicitly. The example uses flushSync so the cleared value reaches the textarea before the imperative focus and close() calls. Closing then records the cleared value, allowing the same query to reopen even when pasted back in one event. This also covers a programmatic form.reset() while the popup is open.
When adapting this to an existing form, use its reset operation for the value, touched state, and errors, and close any active mention session. A programmatic value change is not a native input event.
Preserve the document model
Submission contains plain text. An onSelect callback is a notification of insertion, not an inventory of mentions still present: users can delete or replace text afterward. If mentions need stable identities and round-trip serialization, store them in an editor document using an editor adapter.
The standalone hook uses the same form props:
<textarea {...mention.getInputProps({
value,
onChange: (event) => setValue(event.currentTarget.value),
"aria-label": "Message",
})} />Render the hook's listbox and options with their corresponding getters; see the API reference for the full hook contract.