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.
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.
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
- You want a ready-made rich-text field with a toolbar and sensible product defaults: Lexical gives you an editor framework, so even common behavior is assembled from plugins and companion packages
- You need a settled API with infrequent migrations: 0.49.0 is still pre-1.0 and its release notes contain breaking node and command typing changes in an ordinary monthly release
- Bundle budget is tight for a basic input: the core measured 53.8 KB gzipped before React bindings, history, rich-text, list, link, table or collaboration packages
- You must support browsers older than Chrome 86, Edge 86, Firefox 115 or Safari 15; the current README lists those as the supported floors
- Your durable format must be portable HTML or Markdown with no editor-specific migration plan: Lexical's native JSON serializes registered node types and versions, including your custom-node contracts
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
| Package | Registry | Pick it when |
|---|---|---|
| @tiptap/core | npm | Choose it for a ProseMirror-based headless editor with a more batteries-included extension catalog and common feature kits |
| prosemirror-state | npm | Choose the lower-level ProseMirror stack when schema rigor, mature plugins and direct ecosystem compatibility matter most |
| slate | npm | Choose it for a React-centered editor model where your team already understands Slate elements, leaves and operation transforms |