@lexical/react
@lexical/react is the official React integration layer for Meta's Lexical editor framework. It supplies a composer context, content-editable components, error boundaries, hooks, and plugins for plain text, rich text, history, lists, links, tables, Markdown shortcuts, collaboration, menus, and other editing behaviors. Lexical keeps an immutable, serializable editor-state tree separate from the DOM; React components configure the editor and connect that state to application UI rather than making the editor itself controlled by React props.
Lexical is an excellent foundation for a team intentionally building an editor, not a shortcut to a complete editing product. Choose it for its state model, command system, performance, and customization, then budget for schema design, UI, serialization tests, accessibility, migrations, and collaboration infrastructure.
Use it if
- You are building a custom React editor and need explicit control over its node model, toolbar, commands, serialization, and plugins
- You need structured JSON state plus HTML or Markdown import and export instead of treating contenteditable HTML as the source of truth
- You need custom block or inline nodes, nested editors, tables, mentions, links, or decorator components
- You plan collaborative editing with Yjs and are prepared to choose, secure, and operate a provider
- You want a finished Notion-style editor: Lexical provides editing primitives and example playground code, but you still own the toolbar, menus, styling, media workflow, schema, persistence, migrations, and product behavior
- You only need a short plain-text field: a textarea gives native form behavior, validation, accessibility, and far less code
- You require a settled 1.x compatibility promise: the current package is 0.49.0, and recent docs introduce new extension, import, export, command-priority, and node-state APIs alongside legacy paths
- Your stack cannot meet the peers: 0.49.0 declares React 18 or later, React DOM 18 or later, TypeScript 5.2 or later, and Yjs 13.5.22 or later
- You expect arbitrary HTML round trips to preserve every style: the serialization guide shows that extended inline CSS fidelity can require replacing TextNode and writing custom import conversion logic
Setup reality
The documented starting point is `npm install lexical @lexical/react`. Version 0.49.0 also declares peer requirements for React 18+, React DOM 18+, TypeScript 5.2+, and Yjs 13.5.22+; its export map intentionally sends older TypeScript versions to an error declaration. The React package installs 20 direct dependencies across Lexical feature packages plus Floating UI and devtools core, although applications import narrow subpaths such as `@lexical/react/LexicalComposer`. There are no native builds, credentials, or required config files for a local editor. You must provide `initialConfig` with a unique namespace and error handler, register every non-core node type up front, wrap plugins in `LexicalComposer`, render `ContentEditable`, supply an error boundary, and write all visual styling yourself. Rich text is not enabled merely by mounting the composer; add `RichTextPlugin` and the node classes and plugins for lists, links, tables, code, Markdown, or history that your schema needs. The source of truth is `EditorState`, not the DOM and not a React value prop. Dollar-prefixed functions must run inside `editor.update()`, `editor.read()`, or an editor-state `read()` closure. Updates normally reconcile asynchronously, so immediate server-side serialization after a mutation needs `{ discrete: true }`. `initialConfig.editorState` is consumed only once; changing the prop later does nothing, and collaboration requires `editorState: null` so Yjs owns initialization. OnChange persistence should be debounced and versioned because JSON includes node types and custom-node data that future code must still deserialize. Listeners and commands return unregister functions that React effects must return. HTML import needs DOMParser in the browser or a DOM implementation in headless Node, and full-fidelity custom CSS may need node replacements. CollaborationPlugin does not provide networking, authentication, authorization, awareness storage, document retention, or offline policy; those belong to the selected Yjs provider and your backend. For SSR, render the interactive editor in a client component and avoid making server markup disagree with the one-time initial editor state.
Patterns
Compose a plain-text editorcreate-plain-editor
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
const config = {
namespace: 'CommentEditor',
onError(error) { throw error; },
};
export function Editor() {
return (
<LexicalComposer initialConfig={config}>
<PlainTextPlugin
contentEditable={<ContentEditable aria-label="Comment" />}
placeholder={<div>Write a comment</div>}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
</LexicalComposer>
);
}Lexical supplies behavior, not appearance. Style the editable surface, placeholder, focus state, and error state in your application.
Register nodes for rich textconfigure-rich-text
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
import { ListItemNode, ListNode } from '@lexical/list';
import { LinkNode } from '@lexical/link';
const config = {
namespace: 'ArticleEditor',
nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode],
theme,
onError(error) { console.error(error); },
};
<LexicalComposer initialConfig={config}>
<RichTextPlugin
contentEditable={<ContentEditable aria-label="Article body" />}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<ListPlugin />
<LinkPlugin />
</LexicalComposer>A plugin can only create node types registered in `initialConfig.nodes`; keep all `@lexical/*` packages on the same version.
Get the editor inside a React pluginaccess-editor-instance
import { useEffect } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
function FocusOnMountPlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => {
editor.focus();
}, [editor]);
return null;
}The hook must run under a LexicalComposer. Put behavior in plugin components instead of trying to control the editor through composer props.
Observe and save editor statepersist-json-state
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
function SavePlugin({ save }) {
return (
<OnChangePlugin
ignoreSelectionChange
onChange={(editorState) => {
save(JSON.stringify(editorState));
}}
/>
);
}Debounce real persistence and version your stored document format. Custom node types must remain registered when that JSON is loaded later.
Initialize from stored JSONload-initial-state
const config = {
namespace: 'ArticleEditor',
editorState: storedJson ?? undefined,
nodes: [HeadingNode, QuoteNode],
onError(error) { throw error; },
};
<LexicalComposer initialConfig={config}>
<EditorPlugins />
</LexicalComposer>`editorState` is read only when the editor is created. `undefined` creates the default paragraph; `null` leaves an empty root for collaboration ownership.
Apply JSON after initializationreplace-editor-state
const [editor] = useLexicalComposerContext();
function loadDocument(json) {
const nextState = editor.parseEditorState(json);
if (!nextState.isEmpty()) {
editor.setEditorState(nextState);
}
}Changing `initialConfig` will not reload content. `setEditorState` throws for a state whose root has no nodes and no selection.
Update the document modelinsert-content
import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical';
editor.update(() => {
const paragraph = $createParagraphNode();
paragraph.append($createTextNode('Hello from Lexical'));
$getRoot().append(paragraph);
});Dollar-prefixed helpers must run inside an update or read closure. Never mutate editor nodes from ordinary React render code.
Dispatch a toolbar formatting commandformat-selection
import { FORMAT_TEXT_COMMAND } from 'lexical';
function BoldButton() {
const [editor] = useLexicalComposerContext();
return (
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')}
>
Bold
</button>
);
}Preventing the toolbar mouse-down default keeps the editor selection from being lost before the click command runs.
Register and clean up a typed commandregister-custom-command
const INSERT_TOKEN_COMMAND = createCommand('INSERT_TOKEN_COMMAND');
function TokenPlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerCommand(
INSERT_TOKEN_COMMAND,
(label) => {
$insertNodes([$createTextNode(`{{${label}}}`)]);
return true;
},
COMMAND_PRIORITY_EDITOR,
);
}, [editor]);
return null;
}Return the unregister function from the effect. A listener that returns `true` stops lower-priority command propagation.
Generate HTML from editor stateexport-html
import { $generateHtmlFromNodes } from '@lexical/html';
const html = editor.getEditorState().read(() => {
return $generateHtmlFromNodes(editor, null);
});Custom nodes control their own export behavior. JSON is the safer canonical storage format when HTML cannot represent application-specific node data.
Insert nodes parsed from HTMLimport-html
import { $generateNodesFromDOM } from '@lexical/html';
import { $getRoot, $insertNodes } from 'lexical';
const dom = new DOMParser().parseFromString(html, 'text/html');
editor.update(() => {
const nodes = $generateNodesFromDOM(editor, dom);
$getRoot().select();
$insertNodes(nodes);
});Sanitize hostile HTML before or during conversion and test style fidelity. Node import rules decide what survives.
Let a Yjs document own initial stateinitialize-collaboration
const config = {
namespace: 'SharedDocument',
editorState: null,
nodes: collaborativeNodes,
onError(error) { throw error; },
};
<LexicalComposer initialConfig={config}>
<CollaborationPlugin
id={documentId}
providerFactory={providerFactory}
shouldBootstrap={true}
/>
</LexicalComposer>`editorState: null` is intentional for collaboration. You still must secure the provider, authorize document IDs, persist Yjs updates, and manage awareness.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @tiptap/react | npm | You want a larger extension ecosystem and a higher-level ProseMirror-based React editor API |
| slate-react | npm | You want React-first rendering over a deeply customizable JSON document model and accept more normalization work |
| draft-js | npm | You maintain an existing Draft.js editor and migration cost outweighs the benefits of a newer model |