mrkeyoor.com_
Sat 08 Aug 20:59 UTC
npmWeb Frontendupdated 08 Aug 2026

lexical

Lexical is Meta's framework-agnostic engine for building structured text editors. The core package owns an immutable editor state, selection model, commands, node transforms and DOM reconciliation; it includes basic paragraph and text nodes but not a finished toolbar or full rich-text product. React bindings, history, lists, links, tables, Markdown, HTML and Yjs collaboration live in separate @lexical packages that compose around the same editor state.

Verdict

Lexical is a strong foundation for a serious custom editor, backed by active releases and a wide first-party package family. It is a poor choice for teams expecting a finished rich-text component or a quiet pre-1.0 upgrade path.

API stability2/5Core ideas such as editor.update, editor-state reads, nodes, commands and transforms are consistent, but the package is still at 0.49.0 and monthly releases can break public code. The 0.49.0 notes moved built-in nodes to the $config() protocol, removed several static methods from those nodes and tightened command payload variance. Teams should pin the whole package family, read every release note and budget recurring migration work.
Docs5/5lexical.dev has a conceptual guide, quick starts, package API reference, serialization material, node authoring guidance, browser support, examples, a gallery and a live playground. The repository README routes readers to each of those resources. The main difficulty is volume: examples often span multiple @lexical packages, so readers must watch which package owns each function and distinguish current $config()-based guidance from older tutorials.
Maintenance5/5Version 0.49.0 was published on July 30, 2026, the repository was pushed on August 8, and release notes show ongoing fixes across selection, tables, Markdown, HTML, code highlighting and security. The project has 23,745 stars and a large contributor base. Its 370 combined open issues and pull requests reflect a large active surface rather than inactivity, though the release velocity creates real upgrade work for consumers.
Ecosystem5/5The core recorded 4,545,110 downloads in the measured week and sits under official React, rich-text, plain-text, history, list, link, table, Markdown, HTML and Yjs packages. A public playground, gallery, Discord and extensive examples support adoption. The main constraint is coordination: features are split across many version-locked packages, and third-party nodes must track a still-changing core contract.

Use it if

  • You are building a custom editor product and need precise control over nodes, commands, selection and rendering
  • You want a framework-agnostic core with official React bindings instead of an editor tied permanently to one view layer
  • You need structured JSON state, custom nodes and editor transforms rather than treating contenteditable HTML as the source of truth
  • You expect to add tables, Markdown, collaboration or other capabilities from a coordinated first-party package family
Skip it if

Setup reality

Installing lexical alone gives you the engine, not the React quick start shown at the top of the README. A React editor also needs @lexical/react and usually @lexical/plain-text or @lexical/rich-text plus @lexical/history; lists, links, tables, Markdown, HTML and Yjs each add more packages. Keep every lexical and @lexical package on the same release to avoid private-contract mismatches. Version 0.49.0 declares TypeScript 5.2 or newer as a peer requirement. The editor needs a unique namespace, an onError callback, registered custom nodes and a root contenteditable element attached with setRootElement, or LexicalComposer must do that wiring. You provide all CSS, placeholder UI, toolbar controls, persistence, validation and error reporting. State reads must run inside editorState.read(), while node mutations must run inside editor.update() or a command listener; Lexical's dollar-prefixed helpers depend on that active context. Update listeners fire after reconciliation, so persisting every keystroke needs debouncing and should ignore updates tagged as history or remote collaboration when appropriate. SSR can render surrounding UI, but DOM attachment belongs on the client. Persist editorState.toJSON(), not live node objects, and plan migrations before changing or removing custom node types. The supported browser floor is explicit, and accessibility still depends on the labels, controls and focus behavior you build around the core.

Patterns

Create and mount a framework-agnostic editorcreate-editor

import { createEditor } from 'lexical';

const editor = createEditor({
  namespace: 'CommentEditor',
  nodes: [],
  onError(error) {
    console.error(error);
  },
});

const rootElement = document.getElementById('editor');
rootElement.contentEditable = 'true';
editor.setRootElement(rootElement);

The root element and its styling are your responsibility. In React, LexicalComposer and ContentEditable from @lexical/react perform this mounting work.

Create a paragraph and text nodeinsert-initial-content

import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical';

editor.update(() => {
  const paragraph = $createParagraphNode();
  paragraph.append($createTextNode('Start writing...'));
  $getRoot().append(paragraph);
});

Node creation and mutation must happen inside editor.update or another writable Lexical context. Calling dollar-prefixed helpers outside one throws.

Read the current document as plain textread-plain-text

const text = editor.getEditorState().read(() => {
  return $getRoot().getTextContent();
});

console.log(text);

An EditorState is immutable. read provides a consistent snapshot and must not be used to mutate nodes.

Observe editor-state changeslisten-for-updates

const removeListener = editor.registerUpdateListener(({ editorState, tags }) => {
  const text = editorState.read(() => $getRoot().getTextContent());
  queueSave({ json: editorState.toJSON(), text, tags: [...tags] });
});

// during teardown
removeListener();

The listener can run for every edit. Debounce network saves, unregister on teardown, and use tags to avoid echoing history or collaboration updates.

Serialize editor state to JSONserialize-state

const payload = JSON.stringify(editor.getEditorState().toJSON());
await saveDocument(payload);

JSON contains Lexical node types and versions. Store it as an editor-specific format and retain migrations when custom node schemas change.

Parse and install saved editor staterestore-state

const serialized = await loadDocument();
const nextState = editor.parseEditorState(serialized);
editor.setEditorState(nextState);

Register every custom node class before parsing. Unknown or incompatible node types can make old documents impossible to restore correctly.

Toggle bold on the current range selectionformat-selection

import { $getSelection, $isRangeSelection } from 'lexical';

editor.update(() => {
  const selection = $getSelection();
  if ($isRangeSelection(selection)) {
    selection.formatText('bold');
  }
});

A selection may be null or a non-range NodeSelection. Toolbar controls should reflect that distinction instead of assuming text is always selected.

Define and handle an application commandregister-command

import { COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical';

const INSERT_MENTION_COMMAND = createCommand<string>('INSERT_MENTION_COMMAND');

const unregister = editor.registerCommand(
  INSERT_MENTION_COMMAND,
  (username) => {
    const selection = $getSelection();
    if ($isRangeSelection(selection)) selection.insertText(`@${username}`);
    return true;
  },
  COMMAND_PRIORITY_EDITOR,
);

editor.dispatchCommand(INSERT_MENTION_COMMAND, 'ada');

Returning true stops propagation to lower-priority handlers. Command listeners run in an update context, so node and selection mutations are allowed.

Normalize text nodes with a transformtransform-text-nodes

import { TextNode } from 'lexical';

const removeTransform = editor.registerNodeTransform(TextNode, (node) => {
  const text = node.getTextContent();
  if (text.includes('  ')) {
    node.setTextContent(text.replaceAll('  ', ' '));
  }
});

Transforms rerun until the editor reaches a stable state. Guard every mutation, or a transform that always writes can create a loop.

Switch between editable and read-only modesmake-read-only

editor.setEditable(false);

const removeEditableListener = editor.registerEditableListener((editable) => {
  toolbar.hidden = !editable;
});

// later
editor.setEditable(true);

Read-only mode prevents editing but is not an authorization boundary. Validate permissions and submitted content on the server.

Tag a programmatic updatetag-an-update

editor.update(
  () => {
    $getRoot().clear();
    $getRoot().append($createParagraphNode().append($createTextNode('Imported')));
  },
  { tag: 'document-import' },
);

Update listeners receive the tag set. Use application-specific tags to prevent imports or remote sync from triggering ordinary autosave logic.

Detach the editor and cleanup subscriptionsteardown-editor

removeListener();
unregister();
removeTransform();
removeEditableListener();
editor.setRootElement(null);

Most register methods return cleanup functions. Keep and call them, especially in component remounts, or listeners and transforms accumulate.

Alternatives

PackageRegistryPick it when
@tiptap/corenpmChoose it for a ProseMirror-based headless editor with a more batteries-included extension catalog and common feature kits
prosemirror-statenpmChoose the lower-level ProseMirror stack when schema rigor, mature plugins and direct ecosystem compatibility matter most
slatenpmChoose it for a React-centered editor model where your team already understands Slate elements, leaves and operation transforms