Skip to main content

Overview

Lexical’s reconciliation system is the core mechanism that efficiently updates the DOM to reflect changes in the editor state. Understanding reconciliation is essential for building performant custom nodes and optimizing editor updates.
The reconciliation process runs after all transforms have been applied and converts the immutable EditorState tree into minimal DOM operations.

Architecture

Double-Buffered Updates

Lexical uses a double-buffering approach for managing state:
editor.update(() => {
  // 1. Current EditorState is cloned as work-in-progress
  // 2. Your mutations modify the work-in-progress state
  const root = $getRoot();
  root.append($createParagraphNode());
  
  // 3. Multiple synchronous updates are batched
  // 4. DOM reconciler diffs and applies changes
  // 5. New immutable EditorState becomes current
});
1

Clone State

The current EditorState is cloned to create a work-in-progress copy
2

Apply Mutations

Your update functions modify the work-in-progress state
3

Run Transforms

Node transforms are applied to ensure consistency
4

Reconcile DOM

The reconciler diffs old and new states and updates the DOM
5

Freeze State

The new state is frozen and becomes the current EditorState

How Reconciliation Works

Node Comparison

The reconciler (LexicalReconciler.ts) compares the previous and next node maps:
function $reconcileNode(key: NodeKey, parentDOM: HTMLElement): HTMLElement {
  const prevNode = activePrevNodeMap.get(key);
  const nextNode = activeNextNodeMap.get(key);
  
  // If same instance and not dirty, reuse DOM
  if (prevNode === nextNode && !isDirty) {
    return dom;
  }
  
  // Node was cloned, call updateDOM
  if (nextNode.updateDOM(prevNode, dom, config)) {
    // Replace entire DOM element
    const replacementDOM = $createNode(key, null);
    parentDOM.replaceChild(replacementDOM, dom);
  }
}

updateDOM Return Values

Your updateDOM method should return true only when the DOM element tag needs to change. Return false for property updates.
class CustomNode extends ElementNode {
  updateDOM(
    prevNode: CustomNode,
    dom: HTMLElement,
    config: EditorConfig
  ): boolean {
    // Update properties - return false
    if (prevNode.__color !== this.__color) {
      dom.style.color = this.__color;
    }
    
    // Tag change required - return true
    if (prevNode.__tag !== this.__tag) {
      return true; // Will unmount and recreate
    }
    
    return false;
  }
}

Dirty Tracking

Lexical tracks which nodes have changed to minimize reconciliation work:
// Mark a node as needing reconciliation
node.markDirty();

// Check if node is dirty
if (node.isDirty()) {
  // Node will be reconciled
}

Dirty Types

There are three dirty states tracked internally:
const isDirty = 
  treatAllNodesAsDirty ||         // Full reconcile
  activeDirtyLeaves.has(key) ||   // Text nodes
  activeDirtyElements.has(key);   // Element nodes

Text Content Caching

To optimize performance, Lexical caches text content on element nodes:
// Cached on DOM element during reconciliation
dom.__lexicalTextContent = subTreeTextContent;

// Used to quickly get text without traversing children
const text = element.getTextContent(); // Uses cache if available
Never modify __lexicalTextContent directly. It’s managed by the reconciler.

Direction Reconciliation

Lexical handles bidirectional text efficiently:
export function $getReconciledDirection(
  node: ElementNode
): 'ltr' | 'rtl' | 'auto' | null {
  const direction = node.__dir;
  if (direction !== null) {
    return direction;
  }
  if ($isRootNode(node)) {
    return null;
  }
  const parent = node.getParentOrThrow();
  if (!$isRootNode(parent) || parent.__dir !== null) {
    return null;
  }
  return 'auto'; // Auto-detect from content
}

Performance Optimizations

1. Fast Path for Single Child

if (prevChildrenSize === 1 && nextChildrenSize === 1) {
  const prevFirstChildKey = prevElement.__first!;
  const nextFirstChildKey = nextElement.__first!;
  
  if (prevFirstChildKey === nextFirstChildKey) {
    // Just reconcile the single child
    $reconcileNode(prevFirstChildKey, dom);
  }
}

2. Batch DOM Operations

// Bad - multiple DOM updates
for (const node of nodes) {
  node.getWritable().setColor('red');
}

// Good - single batched update
editor.update(() => {
  for (const node of nodes) {
    node.getWritable().setColor('red');
  }
});

3. Skip Unchanged Subtrees

if (prevNode === nextNode && !isDirty) {
  // Node unchanged, use cached text content
  const text = dom.__lexicalTextContent || prevNode.getTextContent();
  subTreeTextContent += text;
  return dom; // Skip reconciliation
}

Reconciliation Lifecycle

Common Patterns

Creating Custom Reconciliation Logic

class SmartNode extends ElementNode {
  updateDOM(
    prevNode: SmartNode,
    dom: HTMLElement,
    config: EditorConfig
  ): boolean {
    // Check what changed
    const formatChanged = prevNode.__format !== this.__format;
    const indentChanged = prevNode.__indent !== this.__indent;
    
    // Apply incremental updates
    if (formatChanged) {
      // Update format-related styles
      dom.style.textAlign = this.getFormatType();
    }
    
    if (indentChanged) {
      // Update indent
      const indentValue = `calc(${this.__indent} * var(--indent-base))`;
      dom.style.paddingInlineStart = indentValue;
    }
    
    // Never needs full replacement
    return false;
  }
}

Handling Line Breaks

// Lexical adds managed <br> elements for empty lines
function reconcileElementTerminatingLineBreak(
  prevElement: ElementNode | null,
  nextElement: ElementNode,
  dom: HTMLElement
): void {
  const prevLineBreak = isLastChildLineBreakOrDecorator(
    prevElement,
    activePrevNodeMap
  );
  const nextLineBreak = isLastChildLineBreakOrDecorator(
    nextElement,
    activeNextNodeMap
  );
  
  if (prevLineBreak !== nextLineBreak) {
    nextElement.getDOMSlot(dom).setManagedLineBreak(nextLineBreak);
  }
}

Debugging Reconciliation

Enable Reconciliation Logging

// In development, freeze nodes to catch mutations
if (__DEV__) {
  Object.freeze(node);
}

// Track what's being reconciled
editor.registerMutationListener(MyNode, (mutatedNodes, { updateTags }) => {
  console.log('Mutations:', mutatedNodes);
  console.log('Tags:', updateTags);
});

Mutation Listeners

const removeListener = editor.registerMutationListener(
  ParagraphNode,
  (mutations, { prevEditorState, updateTags }) => {
    for (const [nodeKey, mutation] of mutations) {
      console.log(`Node ${nodeKey}: ${mutation}`);
      // mutation can be: 'created' | 'updated' | 'destroyed'
    }
  }
);

Best Practices

Minimize updateDOM Work

Only update what changed. Avoid expensive operations in updateDOM.

Return False When Possible

Only return true from updateDOM when the tag needs to change.

Cache Computed Values

Store expensive computations on the node, not in the DOM.

Batch Updates

Combine related mutations in a single editor.update() call.

Advanced Topics

Node Keys and Identity

All versions of a logical node share the same key:
const node = $createTextNode('hello');
const key = node.getKey(); // e.g., '5'

const writable = node.getWritable();
writable.getKey() === key; // true - same logical node

const latest = node.getLatest();
latest.getKey() === key; // true - same logical node

Read vs Update Context

Inside editor.update(), you see pending state before reconciliation. Use editor.read() or editorState.read() for consistent reconciled state.
// Pending state (transforms not run)
editor.update(() => {
  const selection = $getSelection();
  // May change after transforms
});

// Reconciled state (after transforms)
editor.read(() => {
  const selection = $getSelection();
  // Stable, final state
});

Node Transforms

Learn about transforms that run before reconciliation

Performance

Optimize your editor’s reconciliation performance

Testing

Test reconciliation behavior in your custom nodes

Custom Nodes

Build custom nodes with efficient reconciliation

Build docs developers (and LLMs) love