Lexical’s document is built from a tree of nodes. Every piece of content—from a character of text to a table cell—is represented by a node. Understanding the node system is essential to working with Lexical.
Node Hierarchy
All nodes extend from the base LexicalNode class:
LexicalNode (abstract base)
├── TextNode (text content)
├── LineBreakNode (line breaks)
├── TabNode (tab characters)
├── ElementNode (container nodes)
│ ├── RootNode (editor root)
│ ├── ParagraphNode
│ ├── HeadingNode
│ ├── QuoteNode
│ └── ... custom element nodes
└── DecoratorNode (custom UI)
├── ImageNode
├── TwitterNode
└── ... custom decorators
The LexicalNode Base Class
Every node inherits from LexicalNode:
class LexicalNode {
/** Unique identifier for this node */
__key : string ;
/** Parent node key */
__parent : null | NodeKey ;
/** Previous sibling key */
__prev : null | NodeKey ;
/** Next sibling key */
__next : null | NodeKey ;
/** Node type identifier */
__type : string ;
}
Required Static Methods
Every node class must implement:
getType()
Returns a unique string identifier:
class CustomNode extends ElementNode {
static getType () : string {
return 'custom' ;
}
}
The type string must be unique across all nodes registered in the editor.
clone()
Creates a copy of the node:
static clone ( node : CustomNode ): CustomNode {
return new CustomNode ( node . __key );
}
importJSON()
Deserializes JSON to create a node instance:
static importJSON ( serializedNode : SerializedCustomNode ): CustomNode {
const node = $createCustomNode ();
return node . updateFromJSON ( serializedNode );
}
Required Instance Methods
createDOM()
Creates the DOM element for this node:
createDOM ( config : EditorConfig ): HTMLElement {
const element = document . createElement ( 'div' );
element . className = config . theme . custom || '' ;
return element ;
}
updateDOM()
Updates an existing DOM element. Return true to force recreation:
updateDOM (
prevNode : CustomNode ,
dom : HTMLElement ,
config : EditorConfig
): boolean {
// Return true if we need to recreate the element
// Return false if we can update it in place
return false ;
}
exportJSON()
Serializes the node to JSON:
exportJSON (): SerializedCustomNode {
return {
... super . exportJSON (),
type: 'custom' ,
version: 1 ,
customProperty: this . __customProperty ,
};
}
Node Types
TextNode
Represents text content with formatting:
import { $createTextNode } from 'lexical' ;
editor . update (() => {
const textNode = $createTextNode ( 'Hello, world!' );
// Apply formatting
textNode . setFormat ( 'bold' );
textNode . setStyle ( 'color: red' );
// Modify text
textNode . setTextContent ( 'Updated text' );
// Split at offset
const [ before , after ] = textNode . splitText ( 5 );
});
Text Formatting
Available formats (can be combined):
bold
italic
underline
strikethrough
code
subscript
superscript
textNode . toggleFormat ( 'bold' );
textNode . hasFormat ( 'italic' ); // boolean
ElementNode
Container nodes that can have children:
import { ElementNode } from 'lexical' ;
class CustomBlock extends ElementNode {
static getType () {
return 'custom-block' ;
}
static clone ( node : CustomBlock ) : CustomBlock {
return new CustomBlock ( node . __key );
}
createDOM ( config : EditorConfig ) : HTMLElement {
const dom = document . createElement ( 'div' );
dom . className = 'custom-block' ;
return dom ;
}
updateDOM () : boolean {
return false ;
}
// Element nodes can have children
canBeEmpty () : boolean {
return false ; // This node must have at least one child
}
canInsertTextBefore () : boolean {
return true ;
}
canInsertTextAfter () : boolean {
return true ;
}
}
Working with Children
editor . update (() => {
const element = $createParagraphNode ();
// Append children
element . append (
$createTextNode ( 'First ' ),
$createTextNode ( 'Second' )
);
// Get children
const children = element . getChildren ();
const firstChild = element . getFirstChild ();
const lastChild = element . getLastChild ();
// Insert at index
element . splice ( 1 , 0 , [ $createTextNode ( 'Inserted' )]);
// Clear all children
element . clear ();
});
DecoratorNode
For embedding custom UI components:
import { DecoratorNode } from 'lexical' ;
class ImageNode extends DecoratorNode < JSX . Element > {
__src : string ;
__altText : string ;
static getType () : string {
return 'image' ;
}
static clone ( node : ImageNode ) : ImageNode {
return new ImageNode ( node . __src , node . __altText , node . __key );
}
constructor ( src : string , altText : string , key ?: NodeKey ) {
super ( key );
this . __src = src ;
this . __altText = altText ;
}
createDOM () : HTMLElement {
const span = document . createElement ( 'span' );
return span ;
}
updateDOM () : boolean {
return false ;
}
// The decorator is the React component to render
decorate () : JSX . Element {
return < img src ={ this . __src } alt ={ this . __altText } />;
}
isInline () : boolean {
return true ; // or false for block-level
}
}
Node Keys
Every node has a unique key that persists across clones:
editor . update (() => {
const node = $createTextNode ( 'Hello' );
const key = node . getKey (); // e.g., "4"
// Later, retrieve by key
const retrieved = $getNodeByKey ( key );
// Keys are stable across mutations
const writable = node . getWritable ();
console . log ( writable . getKey () === key ); // true
});
Keys are runtime-only identifiers. They’re not stable across sessions—use JSON serialization for persistence.
Node Manipulation
Getting Nodes
import { $getNodeByKey , $getRoot } from 'lexical' ;
editor . read (() => {
// Get root node
const root = $getRoot ();
// Get by key
const node = $getNodeByKey ( '5' );
// Navigate tree
const parent = node . getParent ();
const nextSibling = node . getNextSibling ();
const prevSibling = node . getPreviousSibling ();
// Get all ancestors
const ancestors = node . getParents ();
// Get top-level block
const topBlock = node . getTopLevelElement ();
});
Inserting Nodes
editor . update (() => {
const node = $createTextNode ( 'New' );
const existingNode = $getRoot (). getFirstChild ();
// Insert before/after
existingNode . insertBefore ( node );
existingNode . insertAfter ( node );
// Append to parent
const paragraph = $createParagraphNode ();
paragraph . append (
$createTextNode ( 'Text 1' ),
$createTextNode ( 'Text 2' )
);
});
Removing Nodes
editor . update (() => {
const node = $getRoot (). getFirstChild ();
// Remove node
node . remove ();
// Remove with preserving empty parent
node . remove ( true );
});
Replacing Nodes
editor . update (() => {
const oldNode = $getRoot (). getFirstChild ();
const newNode = $createParagraphNode ();
// Replace without moving children
oldNode . replace ( newNode );
// Replace and move children to new node
oldNode . replace ( newNode , true );
});
Node Properties
Checking Node Type
import {
$isTextNode ,
$isElementNode ,
$isParagraphNode ,
$isRootNode
} from 'lexical' ;
editor . read (() => {
const node = $getRoot (). getFirstChild ();
if ( $isTextNode ( node )) {
console . log ( 'Text:' , node . getTextContent ());
} else if ( $isParagraphNode ( node )) {
console . log ( 'Paragraph with' , node . getChildrenSize (), 'children' );
}
});
Node State
editor . read (() => {
const node = $getRoot (). getFirstChild ();
// Is this node attached to the editor?
node . isAttached (); // boolean
// Is this node in the current selection?
node . isSelected (); // boolean
// Has this node been marked dirty?
node . isDirty (); // boolean
// Get the latest version of this node
const latest = node . getLatest ();
// Get a writable version (for mutations)
const writable = node . getWritable ();
});
Mutable vs Immutable
Nodes are immutable after reconciliation. During an update, use getWritable():
editor . update (() => {
const node = $getRoot (). getFirstChild ();
// Most mutation methods call getWritable() internally
node . append ( $createTextNode ( 'Added' ));
// Manually get writable version
const writable = node . getWritable ();
writable . __somePrivateProperty = 'value' ;
});
Creating Custom Nodes
Here’s a complete example:
import {
ElementNode ,
type EditorConfig ,
type LexicalNode ,
type NodeKey ,
type SerializedElementNode ,
} from 'lexical' ;
export type SerializedCalloutNode = SerializedElementNode ;
export class CalloutNode extends ElementNode {
static getType () : string {
return 'callout' ;
}
static clone ( node : CalloutNode ) : CalloutNode {
return new CalloutNode ( node . __key );
}
createDOM ( config : EditorConfig ) : HTMLElement {
const dom = document . createElement ( 'div' );
dom . className = 'callout' ;
return dom ;
}
updateDOM ( prevNode : CalloutNode , dom : HTMLElement ) : boolean {
// No updates needed to the element itself
return false ;
}
static importJSON ( serializedNode : SerializedCalloutNode ) : CalloutNode {
return $createCalloutNode ();
}
exportJSON () : SerializedCalloutNode {
return {
... super . exportJSON (),
type: 'callout' ,
version: 1 ,
};
}
// ElementNode specific
canBeEmpty () : boolean {
return false ;
}
isShadowRoot () : boolean {
return false ;
}
}
// Factory function
export function $createCalloutNode () : CalloutNode {
return new CalloutNode ();
}
// Type guard
export function $isCalloutNode (
node : LexicalNode | null | undefined
) : node is CalloutNode {
return node instanceof CalloutNode ;
}
Registering Custom Nodes
import { createEditor } from 'lexical' ;
import { CalloutNode } from './CalloutNode' ;
const editor = createEditor ({
nodes: [ CalloutNode ],
// ... other config
});
Transforms run automatically when nodes are marked dirty:
editor . registerNodeTransform ( TextNode , ( node ) => {
// This runs whenever a TextNode is marked dirty
const text = node . getTextContent ();
if ( text . includes ( '@' )) {
// Convert @mentions to custom nodes
node . setFormat ( 'code' );
}
});
See Transforms for details.
Best Practices
Always use factory functions
Create nodes using $create* functions: // ✅ Good
const node = $createParagraphNode ();
// ❌ Bad (missing $ prefix convention)
const node = new ParagraphNode ();
Always check node types before accessing type-specific properties: if ( $isTextNode ( node )) {
const text = node . getTextContent ();
}
Don't store node references
Store keys, not references: // ✅ Good
const key = node . getKey ();
// Later...
const node = $getNodeByKey ( key );
// ❌ Bad
let savedNode = node ;
// savedNode may be stale later
Implement all required methods
Custom nodes must implement:
static getType()
static clone()
static importJSON()
createDOM()
updateDOM()
exportJSON()
Ensure your type string is unique: // ✅ Good
static getType () {
return 'my-plugin-custom-node' ;
}
// ❌ Bad (conflicts with built-in)
static getType () {
return 'paragraph' ;
}
API Reference
Common Node Methods
class LexicalNode {
// Identity
getKey () : NodeKey ;
getType () : string ;
// Tree navigation
getParent () : ElementNode | null ;
getNextSibling () : LexicalNode | null ;
getPreviousSibling () : LexicalNode | null ;
getTopLevelElement () : ElementNode | null ;
// State
isAttached () : boolean ;
isSelected () : boolean ;
isDirty () : boolean ;
getLatest () : this ;
getWritable () : this ;
// Manipulation
remove ( preserveEmptyParent ?: boolean ) : void ;
replace < N extends LexicalNode >( node : N , includeChildren ?: boolean ) : N ;
insertBefore ( node : LexicalNode ) : LexicalNode ;
insertAfter ( node : LexicalNode ) : LexicalNode ;
// Serialization
exportJSON () : SerializedLexicalNode ;
// DOM
createDOM ( config : EditorConfig , editor : LexicalEditor ) : HTMLElement ;
updateDOM ( prevNode : this , dom : HTMLElement , config : EditorConfig ) : boolean ;
}