Quick Start (Vanilla JS)
Build a Lexical editor by composing extensions. An extension bundles a feature's nodes, configuration, and behavior, including any other extensions it needs. The same extensions work with or without React; for React's mounting and UI components, see Getting Started with React.
Install Lexical
npm install lexical @lexical/extension @lexical/rich-text @lexical/history @lexical/clipboard
Keep lexical and all @lexical/* packages on the same version. Your application
must resolve one copy of each package.
Creating an editor and using it
The following code is read directly from the runnable example below. Start with
an editable element and a read-only view of its JSON state in index.html:
<div class="editor-wrapper">
<div
id="lexical-editor"
contenteditable="true"
role="textbox"
aria-label="Rich text editor"
aria-multiline="true"></div>
</div>
<h4><label for="lexical-state">Editor state:</label></h4>
<textarea id="lexical-state" readonly></textarea>
Define one root extension at module scope. Its register method keeps the JSON
view up to date:
import {ClipboardDOMImportExtension} from '@lexical/clipboard';
import {HistoryExtension} from '@lexical/history';
import {RichTextExtension} from '@lexical/rich-text';
import {
$createParagraphNode,
$createTextNode,
$getRoot,
configExtension,
defineExtension,
} from 'lexical';
export const AppExtension = defineExtension({
$initialEditorState() {
$getRoot().append(
$createParagraphNode().append($createTextNode('Hello world')),
);
},
dependencies: [
RichTextExtension,
ClipboardDOMImportExtension,
configExtension(HistoryExtension, {delay: 300}),
],
name: '@lexical/examples/vanilla-js',
namespace: 'Vanilla JS Demo',
register(editor) {
const stateRef =
document.querySelector<HTMLTextAreaElement>('#lexical-state')!;
return editor.registerUpdateListener(({editorState}) => {
stateRef.value = JSON.stringify(editorState.toJSON(true), null, 2);
});
},
theme: {
paragraph: 'editor-paragraph',
text: {bold: 'editor-text-bold', italic: 'editor-text-italic'},
},
});
Build the editor and attach it to the editable element in the entry point. This
example uses Vite, so the entry point also passes HMRExtension to the builder
to preserve editor state during hot updates. The app extension is independent
of the bundler:
import './styles.css';
import {buildEditorFromExtensions, HMRExtension} from '@lexical/extension';
import {configExtension} from 'lexical';
import {AppExtension} from './AppExtension';
const editor = buildEditorFromExtensions(
AppExtension,
configExtension(HMRExtension, {hot: import.meta.hot ?? null}),
);
editor.setRootElement(document.getElementById('lexical-editor'));
// Accept Vite updates; HMRExtension preserves editor state.
// In an application, also call dispose() when removing the editor permanently.
if (import.meta.hot) {
import.meta.hot.accept();
import.meta.hot.dispose(() => editor.dispose());
}
With other bundlers, omit HMRExtension, its configExtension argument, and the
import.meta.hot block; buildEditorFromExtensions(AppExtension) is sufficient.
For TypeScript with Vite, include /// <reference types="vite/client" /> in
src/vite-env.d.ts to type import.meta.hot. Use Open in StackBlitz below for
the complete project.
RichTextExtension installs editing commands, heading and quote nodes, and its
dependencies, including Dragon speech recognition support. HistoryExtension
adds undo and redo. ClipboardDOMImportExtension routes HTML pasted from other
applications through the DOM import rules
provided by your extensions.
You do not need to register those nodes or behaviors again. Dependencies shared
by multiple extensions are included once. The core lexical package alone does
not provide a complete editing experience; the feature extensions supply it.
Add CSS for the editor layout and the paragraph, bold, and italic theme classes:
.editor-wrapper {
border: 2px solid gray;
}
#lexical-editor {
min-height: 8em;
padding: 1em;
}
#lexical-state {
width: 100%;
height: 300px;
}
.editor-paragraph {
margin: 0 0 1em;
}
.editor-text-bold {
font-weight: bold;
}
.editor-text-italic {
font-style: italic;
}
See Theming for more options. Errors throw by default; supply
onError on your root extension only if your application needs custom handling.
Configuring features
Use configExtension to override a dependency's configuration. For example, to
group typing into undo steps with a 300 ms interval, the example configures
HistoryExtension in dependencies with:
import {configExtension} from 'lexical';
configExtension(HistoryExtension, {delay: 300});
Choose the extension graph when creating the editor. See Included Extensions for more features and Creating an Extension to add your own.
Cleanup
The runnable vanilla examples keep their module-level root extension in
AppExtension.ts. Its register function owns the JSON view and any DOM event
handlers, returning their cleanup functions. The entry point builds the editor
and attaches its root. See Adding Data to Nodes
for a complete example using event registration helpers and HMRExtension.
editor.setRootElement(null) detaches the editable element; you can attach
another element later. When the editor is no longer needed, dispose it:
editor.dispose();
Disposal detaches the root and runs the cleanup functions returned by extensions. Call it when your view is removed, including when replacing the editor during hot module reload.
Working with Editor States
Lexical's source of truth is its immutable EditorState, which contains the node
tree and selection. The editor reconciles that state to the DOM.
Functions prefixed with $, such as $getRoot(), need a synchronous Lexical read
or update context. Use editor.read('latest', ...) for reading and editor.update() for
changes. Initialization callbacks, node transforms, and command listeners also
run in an update context.
const text = editor.read('latest', () => $getRoot().getTextContent());
editor.update(() => {
$getRoot().append(
$createParagraphNode().append($createTextNode('Another paragraph')),
);
});
The update callback runs synchronously, but Lexical normally batches DOM commits.
editor.read('latest', ...) reads the latest committed state without flushing
pending updates. This is usually the mode you want. Keep $ calls inside the
callback, and do not use await inside it.
If you need to read the result immediately after a programmatic update, use
'force-commit' to flush pending updates first:
const updatedText = editor.read('force-commit', () =>
$getRoot().getTextContent(),
);
Use this only when you need the synchronous commit; ordinary reads can leave Lexical's update batching intact.
Saving and restoring state
toJSON() returns a JSON-compatible object; JSON.stringify() turns it into a
string. To save the latest committed state:
const savedState = editor.read('latest', () =>
JSON.stringify(editor.getEditorState().toJSON()),
);
To initialize an editor from saved JSON, set $initialEditorState: savedState on
its root extension instead of the initialization function. To explicitly replace
an existing editor's document:
editor.setEditorState(editor.parseEditorState(savedState));
See Editor State for details.
Responding to changes
Put listeners in an extension's register method and return their cleanup
function. For example, add this extension to your root's dependencies:
const LogChangesExtension = defineExtension({
name: 'LogChanges',
register(editor) {
return editor.registerUpdateListener(
({editorState, dirtyElements, dirtyLeaves}) => {
// Ignore updates that only change the selection.
if (dirtyElements.size === 0 && dirtyLeaves.size === 0) {
return;
}
console.log(JSON.stringify(editorState.toJSON(true)));
},
);
},
});
Use a node transform to change content in response to an edit. An update listener observes committed changes; starting another update inside it adds an unnecessary reconciliation.
Putting it together
This runnable example uses one root extension for rich text, HTML paste, history,
initial content, and a JSON debug view. The view uses editorState.toJSON(true)
to omit fields that parsing restores to their default values, and keeps the JSON
indented for readability: