Lexical is the editor framework behind Facebook's and Instagram's composers, and its design makes grammar checking pleasant in one respect and awkward in another. Pleasant: every mutation runs inside editor.update(), so an accepted correction is automatically part of the undo stack and every collaborator's document. Awkward: your proofreading API speaks in flat string offsets, and Lexical speaks in node keys — so the whole integration hinges on a clean translation between the two.

This guide builds a GrammarCheckPlugin for a React Lexical editor: a debounced proofreading loop, suggestions rendered as marks, a popover that explains each one, and accept/dismiss actions that behave like normal edits. It assumes a working LexicalComposer setup. If you use ProseMirror or TipTap instead, the TipTap and ProseMirror integration covers the same problem in that framework's idiom; the offset-mapping section below is the part that differs most.

The plugin skeleton

Lexical plugins are React components rendered inside LexicalComposer. They grab the editor instance and register listeners:

import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useEffect } from "react";

export function GrammarCheckPlugin() {
  const [editor] = useLexicalComposerContext();

  useEffect(() => {
    return editor.registerUpdateListener(({ editorState, dirtyElements, dirtyLeaves }) => {
      if (dirtyElements.size === 0 && dirtyLeaves.size === 0) return; // selection-only change
      const text = editorState.read(() => $getRoot().getTextContent());
      scheduleProofread(text);
    });
  }, [editor]);

  return null;
}

Two details matter immediately. registerUpdateListener fires on selection changes as well as content changes, so the dirty-set guard prevents a proofreading request every time the caret moves. And $getRoot().getTextContent() must be called inside a read() — Lexical's $ functions only work within an editor context.

getTextContent() joins block-level nodes with \n\n and inline breaks with \n. That exact string is what you send to the API and what every returned offset refers to, so it must also be the string you walk when mapping offsets back. Deriving it two different ways is the single most common source of misplaced underlines.

The debounced request loop

Requests must be debounced, and stale responses must be discarded. A monotonic version counter does both:

let version = 0;
let timer: ReturnType<typeof setTimeout> | undefined;

function scheduleProofread(text: string) {
  const requested = ++version;
  clearTimeout(timer);
  timer = setTimeout(async () => {
    const result = await amberPen.proofread({
      text,
      dictionary: productTerms,
      properNouns: userNames,
    });
    if (requested !== version) return; // the document moved on; drop this response
    applySuggestions(result.edits, text);
  }, 600);
}

Every edit in result.edits is { start, end, replacement } with UTF-16 offsets into the exact text you sent — sorted and non-overlapping, so you can walk them in one pass without interval arithmetic. The dictionary and properNouns fields keep your product names and your users' names from being "corrected"; see custom dictionaries for how to scope them per user.

The 600 ms debounce is a starting point. Below ~300 ms you'll send requests mid-word and pay for corrections nobody sees; above ~1 s the feature feels detached from typing.

Mapping offsets to Lexical points

This is the core of the integration. An offset like 1,284 must become { node: TextNode, offset: 37 }. Walk the text nodes in document order, accumulating the same separators getTextContent() inserted:

import { $getRoot, $isElementNode, $isTextNode, type TextNode } from "lexical";

type Point = { node: TextNode; offset: number };

function buildOffsetIndex(): Array<{ node: TextNode; start: number; end: number }> {
  const index: Array<{ node: TextNode; start: number; end: number }> = [];
  let cursor = 0;

  const visit = (node: unknown, isLastChild: boolean) => {
    if ($isTextNode(node)) {
      const length = node.getTextContent().length;
      index.push({ node, start: cursor, end: cursor + length });
      cursor += length;
      return;
    }
    if ($isElementNode(node)) {
      const children = node.getChildren();
      children.forEach((child, i) => visit(child, i === children.length - 1));
      if (!node.isInline() && !isLastChild) cursor += 2; // the "\n\n" between blocks
    }
  };

  const root = $getRoot();
  const children = root.getChildren();
  children.forEach((child, i) => visit(child, i === children.length - 1));
  return index;
}

function pointAt(index: ReturnType<typeof buildOffsetIndex>, offset: number): Point | null {
  const entry = index.find((e) => offset >= e.start && offset <= e.end);
  return entry ? { node: entry.node, offset: offset - entry.start } : null;
}

Build the index once per batch of suggestions, not once per suggestion — find over a fresh walk for every edit turns a 200-suggestion document into a visible pause.

Verify the walk before you trust it. The cheapest possible check catches every separator bug:

const index = buildOffsetIndex();
const reconstructed = index.map((e) => e.node.getTextContent()).join("");
// compare lengths and spot-check a few offsets against the text you sent

If your schema has decorator nodes, tables, or inline images, they contribute to getTextContent() differently than you expect, and this assertion is where you'll find out — not in a bug report about underlines drifting three characters to the left.

Rendering suggestions as marks

Lexical ships MarkNode in @lexical/mark, designed for exactly this: an inline wrapper carrying a set of IDs, which splits and merges correctly as text is edited. Use it rather than inventing a decoration layer:

import { $createRangeSelection, $setSelection } from "lexical";
import { $wrapSelectionInMarkNode } from "@lexical/mark";

function applySuggestions(edits: TextEdit[], sentText: string) {
  editor.update(() => {
    clearExistingMarks();
    const index = buildOffsetIndex();

    for (const edit of edits) {
      const from = pointAt(index, edit.start);
      const to = pointAt(index, edit.end);
      if (!from || !to) continue;

      const selection = $createRangeSelection();
      selection.anchor.set(from.node.getKey(), from.offset, "text");
      selection.focus.set(to.node.getKey(), to.offset, "text");
      $wrapSelectionInMarkNode(selection, false, edit.id);
      suggestionsById.set(edit.id, edit);
    }
  }, { tag: "history-merge" });
}

The history-merge tag is important: it keeps the mark-wrapping out of the undo stack, so a user pressing Ctrl+Z undoes their typing rather than the appearance of an underline. Style the marks with CSS on the theme's mark class — a text-decoration: underline wavy in your suggestion color is enough.

The suggestionsById map is what a popover reads. Keep the API's data there rather than stuffing it into the node, so the node stays cheap to serialize.

Accepting a correction

Accepting is an ordinary Lexical edit — select the marked range and insert the replacement:

import { $isMarkNode } from "@lexical/mark";
import { $nodesOfType, $createRangeSelection, $setSelection } from "lexical";
import { MarkNode } from "@lexical/mark";

function acceptSuggestion(id: string) {
  const edit = suggestionsById.get(id);
  if (!edit) return;

  editor.update(() => {
    const mark = $nodesOfType(MarkNode).find((n) => n.getIDs().includes(id));
    if (!mark) return;

    const selection = $createRangeSelection();
    selection.anchor.set(mark.getKey(), 0, "element");
    selection.focus.set(mark.getKey(), mark.getChildrenSize(), "element");
    $setSelection(selection);
    selection.insertText(edit.replacement);
    suggestionsById.delete(id);
  });
}

Because this runs in a normal editor.update() with no history-merge tag, it lands in the undo stack as one step and — if you use @lexical/yjs — propagates to collaborators like any other edit. That is the payoff for doing the work inside Lexical's model instead of painting an overlay on top of it.

Dismissing is simpler: remove the ID from the mark (mark.deleteID(id)), and unwrap the node if it has no IDs left. Track dismissed edits in a set keyed on the text content plus category, so the same suggestion doesn't return on the next request and re-annoy the user.

The popover

The popover is ordinary React. Register a click handler, resolve the mark under the cursor, and position a floating element with the DOM rect:

const element = editor.getElementByKey(mark.getKey());
const rect = element?.getBoundingClientRect();

Show the replacement, and — if you use evaluate mode — the explanation and category that come with each edit. For education products that explanation is the entire value of the feature, not a nicety; grammar feedback that teaches makes the case in detail.

Scaling past the naive loop

Everything above re-proofreads the whole document on every pause. That's correct for short documents and unaffordable for long ones — the arithmetic is unforgiving, and we worked it through in the hidden cost of proofreading long documents. Two changes fix it without touching the plugin's structure:

  • Incremental proofreading — pass a stable textId per document and only the changed regions are re-processed. Offsets still come back relative to the full text, so pointAt is unchanged, and the response still covers the whole document, so suggestionsById can be rebuilt from it as usual.
  • Streaming mode — receive complete edit batches as sections finish, so the first underlines appear in a few hundred milliseconds on a long document. Wrap each batch in its own editor.update(); because you rebuild the offset index per batch, no additional bookkeeping is needed.

The shape of the thing

The plugin is about 150 lines, and only one of its parts is genuinely tricky — the offset walk, which is worth the verification assertion above. Everything else is Lexical doing what it's designed for: marks that survive editing, updates that are undoable, and a React tree that renders the rest.

The division of labor is the point. The API returns inspectable edits with stable offsets; your editor decides when to show them, how they look, and what happens on accept. Grab a free test key, drop GrammarCheckPlugin into your composer, and you'll have working underlines before the afternoon is out. If you're still choosing a provider, the grammar checker API comparison covers the seven worth evaluating.