Recipes

Controlled forms

Validate, submit, and reset a real form while Mention preserves ordinary textarea behavior.

Markdown Source

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.

Type @ to mention Alice or Bob. A message is required.

This demo keeps submissions on this page.

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.

MessageForm.tsx
"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:

Standalone hook
<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.