Skip to main content
The EditorState is an immutable snapshot representing the complete state of a Lexical editor at a specific moment. It contains the node tree (the document structure) and the current selection.

Core Concept

Lexical uses an immutable state model similar to React or Redux:
  1. The current state is read-only
  2. Updates create a new state (copy-on-write)
  3. Changes are batched and reconciled to the DOM
  4. Previous states remain unchanged
This architecture enables:
  • Time-travel debugging
  • Efficient change detection
  • Predictable state management
  • Safe concurrent reads

Structure

An EditorState contains:
class EditorState {
  /** Map of all nodes by their keys */
  _nodeMap: NodeMap; // Map<NodeKey, LexicalNode>
  
  /** Current selection (or null) */
  _selection: null | BaseSelection;
  
  /** Whether this state is read-only */
  _readOnly: boolean;
  
  /** Whether this state requires synchronous flush */
  _flushSync: boolean;
}

The Node Map

The _nodeMap is a flat Map containing all nodes in the editor, indexed by their unique keys:
type NodeMap = Map<NodeKey, LexicalNode>;
While the nodes have parent/child relationships forming a tree structure, they’re stored in a flat map for efficient lookup.
editor.read(() => {
  const state = editor.getEditorState();
  const nodeMap = state._nodeMap;
  
  // Access any node by key
  const node = nodeMap.get('5'); 
});
The node map is an internal detail. Use $getNodeByKey() instead of accessing _nodeMap directly.

Accessing Editor State

Getting the Current State

const editorState = editor.getEditorState();
This returns the latest reconciled state. Any pending updates are flushed before returning.

Reading from State

Use the read() method to execute code with the state context:
const textContent = editor.getEditorState().read(() => {
  const root = $getRoot();
  return root.getTextContent();
});
Or use the editor’s read() method (which flushes pending updates first):
const textContent = editor.read(() => {
  const root = $getRoot();
  return root.getTextContent();
});
Never store node references across state boundaries. Nodes from one state are not valid in another state.
// ❌ Bad - node reference becomes stale
let myNode;
editor.read(() => {
  myNode = $getRoot().getFirstChild();
});

editor.update(() => {
  myNode.remove(); // May reference wrong version!
});

// ✅ Good - use keys to reference nodes
let nodeKey;
editor.read(() => {
  nodeKey = $getRoot().getFirstChild()?.getKey();
});

editor.update(() => {
  const node = $getNodeByKey(nodeKey);
  if (node) {
    node.remove();
  }
});

Immutability

Why Immutable?

Editor states are deeply frozen after reconciliation:
editor.update(() => {
  const root = $getRoot();
  const paragraph = $createParagraphNode();
  root.append(paragraph);
  
  // After reconciliation, the state becomes read-only
});

// Outside update, trying to mutate throws an error
const state = editor.getEditorState();
state._nodeMap.set('key', node); // ❌ Throws in dev mode

Copy-on-Write

When you call mutating methods on nodes during an update, Lexical automatically clones them:
editor.update(() => {
  const paragraph = $getRoot().getFirstChild();
  
  // This actually creates a mutable clone and updates it
  paragraph.append($createTextNode('New text'));
  
  // The original paragraph node from the previous state is unchanged
});
This is handled transparently by the getWritable() method that all mutation methods call internally.

Creating and Setting States

Creating an Empty State

import { createEmptyEditorState } from 'lexical';

const emptyState = createEmptyEditorState();

Cloning a State

const currentState = editor.getEditorState();
const clonedState = currentState.clone();

// With a new selection
const clonedWithSelection = currentState.clone(newSelection);

Setting a State

// Parse from JSON
const state = editor.parseEditorState(jsonString);

// Set it on the editor
editor.setEditorState(state, {
  tag: 'history-merge' // Optional tag
});
setEditorState() triggers a full reconciliation and will run transforms. For simple state restoration, this is the right approach.

Serialization

To JSON

Serialize the complete state:
const json = editor.getEditorState().toJSON();
Example output:
{
  "root": {
    "type": "root",
    "version": 1,
    "children": [
      {
        "type": "paragraph",
        "version": 1,
        "children": [
          {
            "type": "text",
            "version": 1,
            "text": "Hello, world!",
            "format": 0,
            "style": "",
            "mode": "normal",
            "detail": 0
          }
        ],
        "format": "",
        "indent": 0,
        "textFormat": 0,
        "textStyle": "",
        "direction": null
      }
    ],
    "format": "",
    "indent": 0,
    "direction": null
  }
}

From JSON

Parse JSON back into state:
// As a JSON object
const state = editor.parseEditorState(jsonObject);

// As a string
const state = editor.parseEditorState(jsonString);

// With an update function
const state = editor.parseEditorState(jsonString, () => {
  // Optionally modify state during parse
  const root = $getRoot();
  // ... modifications
});

editor.setEditorState(state);

State in Update Listeners

Update listeners receive both the new state and the previous state:
editor.registerUpdateListener(
  ({ editorState, prevEditorState, dirtyElements, dirtyLeaves }) => {
    // Compare states
    const oldText = prevEditorState.read(() => {
      return $getRoot().getTextContent();
    });
    
    const newText = editorState.read(() => {
      return $getRoot().getTextContent();
    });
    
    if (oldText !== newText) {
      console.log('Text changed!');
    }
  }
);

Dirty Tracking

The update listener payload includes information about what changed:
editor.registerUpdateListener(({ dirtyElements, dirtyLeaves, tags }) => {
  // dirtyElements: Map<NodeKey, boolean>
  // Keys of ElementNodes that changed
  
  // dirtyLeaves: Set<NodeKey>
  // Keys of leaf nodes (text, linebreak, etc.) that changed
  
  // tags: Set<string>
  // Tags associated with this update
  
  for (const [key, intentional] of dirtyElements) {
    if (intentional) {
      console.log('Node', key, 'was explicitly marked dirty');
    }
  }
});

Selection

The editor state includes the current selection:
editor.read(() => {
  const state = editor.getEditorState();
  const selection = state._selection;
  
  if (selection) {
    // Selection exists
    if ($isRangeSelection(selection)) {
      console.log('Range selection');
    } else if ($isNodeSelection(selection)) {
      console.log('Node selection');
    }
  } else {
    console.log('No selection');
  }
});
See Selection for details.

Type Guards

import { $isEditorState } from 'lexical';

if ($isEditorState(value)) {
  // value is an EditorState
}

Checking if Empty

const state = editor.getEditorState();

if (state.isEmpty()) {
  // State only contains the root node and has no selection
  console.log('Editor is empty');
}
An empty state has exactly one node (the root) and no selection.

State Transitions

Here’s how states transition during updates:
  1. Current State: The immutable current state
  2. Pending State: A writable clone created for the update
  3. Modified State: State after your mutations
  4. Transformed State: State after automatic transforms run
  5. New State: Frozen and becomes the new current state
  6. DOM Updated: Reconciler applies minimal changes to DOM

Best Practices

Always use editor.update() to make changes:
// ❌ Bad
editor.getEditorState()._nodeMap.set(key, node);

// ✅ Good
editor.update(() => {
  const node = $getNodeByKey(key);
  node?.remove();
});
Store node keys instead of node references when you need to reference nodes across different contexts:
// ✅ Good
const key = editor.read(() => $getRoot().getFirstChild()?.getKey());

editor.update(() => {
  const node = $getNodeByKey(key);
  // Work with node
});
Multiple update() calls are automatically batched if synchronous:
// These are batched into a single reconciliation
editor.update(() => { /* change 1 */ });
editor.update(() => { /* change 2 */ });
editor.update(() => { /* change 3 */ });
  • Use editor.read() for reading without changes
  • Use editor.update() for making mutations
  • Don’t nest update() inside read() or vice versa (except read() at end of update())

Type Signature

interface SerializedEditorState<
  T extends SerializedLexicalNode = SerializedLexicalNode
> {
  root: SerializedRootNode<T>;
}

class EditorState {
  _nodeMap: NodeMap;
  _selection: null | BaseSelection;
  _flushSync: boolean;
  _readOnly: boolean;
  
  isEmpty(): boolean;
  
  read<V>(
    callbackFn: () => V,
    options?: EditorStateReadOptions
  ): V;
  
  clone(selection?: null | BaseSelection): EditorState;
  
  toJSON(): SerializedEditorState;
}
  • Editor - The LexicalEditor instance
  • Updates - Making changes to state
  • Nodes - The building blocks of state
  • Selection - Managing the cursor and selection

Build docs developers (and LLMs) love