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
});
Clone State
The current EditorState is cloned to create a work-in-progress copy
Apply Mutations
Your update functions modify the work-in-progress state
Run Transforms
Node transforms are applied to ensure consistency
Reconcile DOM
The reconciler diffs old and new states and updates the DOM
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:
LexicalReconciler.ts
Update Flow
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
}
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