Editor State
Why is it necessary?
With Lexical, the source of truth is not the DOM, but rather an underlying state model that Lexical maintains and associates with an editor instance.
While HTML is great for storing rich text content it's often "way too flexible" when it comes to text editing. For example the following lines of content will produce equal outcome:
<i><b>Lexical</b></i>
<i><b>Lex<b><b>ical</b></i>
<b><i>Lexical</i></b>
See rendered version!
Of course, there are ways to normalize all these variants to a single canonical form, however this would require DOM manipulation and so re-rendering of the content. And to overcome this we can use Virtual DOM, or State.
On top of that it allows to decouple content structure from content formatting. Let's look at this example stored in HTML:
<p>Why did the JavaScript developer go to the bar? <b>Because he couldn't handle his <i>Promise</i>s</b></p>
In contrast, Lexical decouples structure from formatting by offsetting this information to attributes. This allows us to have canonical document structure regardless of the order in which different styles were applied.

Understanding the Editor State
You can get the latest editor state from an editor by calling editor.getEditorState().
Editor states have two phases:
- During an update they can be thought of as "mutable". See "Updating state" below to mutate an editor state.
- After an update, the editor state is then locked and deemed immutable from there on. This editor state can therefore be thought of as a "snapshot".
Editor states contain two core things:
- The editor node tree (starting from the root node).
- The editor selection (which can be null).
Editor states are serializable to JSON, and the editor instance provides a useful method to deserialize stringified editor states.
Here's an example of how you can initialize editor with some state and then persist it:
// Get editor initial state (e.g. loaded from backend)
const loadContent = async () => {
// 'empty' editor
const value = '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}';
return value;
}
const initialEditorState = await loadContent();
const editor = createEditor(...);
registerRichText(editor, initialEditorState);
...
// Handler to store content (e.g. when user submits a form)
const onSubmit = () => {
await saveContent(JSON.stringify(editor.getEditorState()));
}
For React it could be something like the following:
const initialEditorState = await loadContent();
const editorStateRef = useRef(undefined);
<LexicalComposer initialConfig={{
editorState: initialEditorState
}}>
<LexicalRichTextPlugin />
<LexicalOnChangePlugin onChange={(editorState) => {
editorStateRef.current = editorState;
}} />
<Button label="Save" onPress={() => {
if (editorStateRef.current) {
saveContent(JSON.stringify(editorStateRef.current))
}
}} />
</LexicalComposer>
Lexical reads initialConfig.editorState only once (when the editor is created); passing
a different value later won't be reflected. See "Updating state" below for the proper way
to change editor state after initialization.
The editorState field accepts:
- a JSON string, parsed with
editor.parseEditorState()(as in the example above); - an
EditorStateinstance, applied directly witheditor.setEditorState(); - a function
(editor) => void, run insideeditor.update(...)and invoked only if the root is still empty (so a populated root is left untouched); null, which skips default initialization entirely. Use this with the collaboration plugin so that the Yjs document, not Lexical, owns the initial state.
Omitting the field (or passing undefined) seeds the root with a default empty
ParagraphNode. The two are not interchangeable: null leaves the root with no children,
while undefined produces a single empty line. If your loadContent may yield null or
undefined for new documents, coalesce to undefined (e.g. (await loadContent()) ?? undefined)
so the editor still gets the default paragraph rather than the collab-style uninitialized
state.
Updating state
For a deep dive into how state updates work, check out this blog post by Lexical contributor @DaniGuardiola.
The most common way to update the editor is to use editor.update(). Calling this function
requires a function to be passed in that will provide access to mutate the underlying
editor state. When starting a fresh update, the current editor state is cloned and
used as the starting point. From a technical perspective, this means that Lexical leverages a technique
called double-buffering during updates. There's the "current" frozen editor state to represent what was
most recently reconciled to the DOM, and another work-in-progress "pending" editor state that represents
future changes for the next reconciliation.
Reconciling an update is typically an async process that allows Lexical to batch multiple synchronous
updates of the editor state together in a single update to the DOM – improving performance. When
Lexical is ready to commit the update to the DOM, the underlying mutations and changes in the update
batch will form a new immutable editor state. Calling editor.getEditorState() will then return the
latest editor state based on the changes from the update.
Here's an example of how you can update an editor instance:
import {$getRoot, $getSelection} from 'lexical';
import {$createParagraphNode} from 'lexical';
// Inside the `editor.update` you can use special $ prefixed helper functions.
// These functions cannot be used outside the closure, and will error if you try.
// (If you're familiar with React, you can imagine these to be a bit like using a hook
// outside of a React function component).
editor.update(() => {
// Get the RootNode from the EditorState
const root = $getRoot();
// Get the selection from the EditorState
const selection = $getSelection();
// Create a new ParagraphNode
const paragraphNode = $createParagraphNode();
// Create a new TextNode
const textNode = $createTextNode('Hello world');
// Append the text node to the paragraph
paragraphNode.append(textNode);
// Finally, append the paragraph to the root
root.append(paragraphNode);
});
Another way to set state is setEditorState method, which replaces current state with the one passed as an argument.
Here's an example of how you can set editor state from a stringified JSON:
const editorState = editor.parseEditorState(editorStateJSONString);
editor.setEditorState(editorState);
setEditorState throws when called with an EditorState that satisfies
editorState.isEmpty() — i.e. the root is the only node and there is no selection. The
message is "setEditorState: the editor state is empty. Ensure the editor state's root node never becomes empty.". The EditorState produced by initializing with
editorState: null and never appending content (typical for a collaboration document
before peers connect) has exactly this shape, so persisting and reloading such a state
will fail at the setEditorState call. Note that parseEditorState itself succeeds — the
throw lands on the apply step. Either guard with editorState.isEmpty() before calling
setEditorState, or seed the document with a ParagraphNode before serializing.
State update listener
If you want to know when the editor updates so you can react to the changes, you can add an update listener to the editor, as shown below:
editor.registerUpdateListener(({editorState}) => {
// The latest EditorState can be found as `editorState`.
// To read the contents of the EditorState, use the following API:
editorState.read(() => {
// Just like editor.update(), .read() expects a closure where you can use
// the $ prefixed helper functions.
});
});
When are Listeners, Transforms, and Commands called?
There are several types of callbacks that can be registered with the editor that are related to updates of the Editor State.
| Callback Type | When It's Called |
|---|---|
| Update Listener | After reconciliation |
| Mutation Listener | After reconciliation |
| Node Transform | During editor.update(), after the callback finishes, if any instances of the node type they are registered for were updated |
| Command | As soon as the command is dispatched to the editor (called from an implicit editor.update()) |
Synchronous reconciliation with discrete updates
While commit scheduling and batching are normally what we want, they can sometimes get in the way.
Consider this example: you're trying to manipulate an editor state in a server context and then persist it in a database.
editor.update(() => {
// manipulate the state...
});
saveToDatabase(editor.getEditorState().toJSON());
This code will not work as expected, because the saveToDatabase call will happen before the state has been committed.
The state that will be saved will be the same one that existed before the update.
Fortunately, the discrete option for LexicalEditor.update forces an update to be immediately committed.
editor.update(() => {
// manipulate the state...
}, {discrete: true});
saveToDatabase(editor.getEditorState().toJSON());
Cloning state
Lexical state can be cloned, optionally with custom selection. One of the scenarios where you'd want to do it is setting editor's state but not forcing any selection:
// Passing `null` as a selection value to prevent focusing the editor
editor.setEditorState(editorState.clone(null));