# Controlled forms

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

Canonical: https://reactmention.com/docs/recipes/controlled-value



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 [#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.

<div className="docs-example not-prose">
  <MessageForm />
</div>

This example keeps submissions on the page. In your application, send the validated form data through your existing submission handler.

## Use the complete example [#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.

```tsx title="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 [#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-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 [#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](/docs/rich-text).

The standalone hook uses the same form props:

```tsx title="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](/docs/api-reference) for the full hook contract.
