Node transforms are one of Lexical’s most powerful features. They allow you to automatically respond to changes in specific node types, enabling reactive patterns without manual coordination.
Transforms are callbacks that run automatically when nodes of a specific type are marked dirty during an update. They’re perfect for:
Enforcing consistency rules
Normalizing content
Auto-formatting text
Converting between node types
Maintaining invariants
editor . registerNodeTransform ( TextNode , ( node ) => {
// This runs whenever a TextNode is marked dirty
const text = node . getTextContent ();
if ( text . includes ( '@' )) {
// Automatically convert @mentions
node . setFormat ( 'code' );
}
});
Use editor.registerNodeTransform() to register a transform:
import { TextNode } from 'lexical' ;
const removeTransform = editor . registerNodeTransform (
TextNode ,
( node ) => {
// Transform logic here
// This node is guaranteed to be a TextNode
}
);
// Clean up when done
removeTransform ();
type Transform < T extends LexicalNode > = ( node : T ) => void ;
editor . registerNodeTransform < T extends LexicalNode > (
nodeClass : Klass < T > ,
transform : Transform < T >
): () => void ;
Transforms run during the update cycle:
Execution Order
Leaf nodes first : TextNode, LineBreakNode, etc.
Element nodes second : ParagraphNode, HeadingNode, etc.
Root node last : RootNode transforms run after all descendants
Repeat if needed : If transforms mark more nodes dirty, the cycle repeats
Transforms can trigger an infinite loop if they continuously mark the same node dirty. Lexical will throw an error after 100 iterations.
Automatically format text based on patterns:
editor . registerNodeTransform ( TextNode , ( node ) => {
const text = node . getTextContent ();
// Auto-bold text between **asterisks**
if ( text . startsWith ( '**' ) && text . endsWith ( '**' )) {
const content = text . slice ( 2 , - 2 );
node . setTextContent ( content );
node . toggleFormat ( 'bold' );
}
});
Converting Node Types
Replace nodes with different types:
import { $isTextNode } from 'lexical' ;
import { $createHashtagNode } from './HashtagNode' ;
editor . registerNodeTransform ( TextNode , ( node ) => {
const text = node . getTextContent ();
// Convert #hashtags to custom nodes
const hashtagRegex = /# ( \w + ) / ;
const match = text . match ( hashtagRegex );
if ( match ) {
const [ fullMatch , tag ] = match ;
const index = text . indexOf ( fullMatch );
if ( index === 0 && text . length === fullMatch . length ) {
// Replace entire node
const hashtagNode = $createHashtagNode ( tag );
node . replace ( hashtagNode );
} else if ( index === 0 ) {
// Split and replace
const [ hashtagText , rest ] = node . splitText ( fullMatch . length );
const hashtagNode = $createHashtagNode ( tag );
hashtagText . replace ( hashtagNode );
} else if ( index + fullMatch . length === text . length ) {
const [ before , hashtagText ] = node . splitText ( index );
const hashtagNode = $createHashtagNode ( tag );
hashtagText . replace ( hashtagNode );
} else {
const [ before , hashtagText , after ] = node . splitText (
index ,
index + fullMatch . length
);
const hashtagNode = $createHashtagNode ( tag );
hashtagText . replace ( hashtagNode );
}
}
});
Enforcing Constraints
Maintain invariants on nodes:
editor . registerNodeTransform ( HeadingNode , ( node ) => {
// Headings must have text content
if ( node . getChildrenSize () === 0 ) {
node . append ( $createTextNode ( '' ));
}
// Headings can't be empty after normalization
if ( node . getTextContent (). trim () === '' ) {
node . remove ();
}
});
Parent-Child Validation
Ensure correct node hierarchy:
import { $isListItemNode } from '@lexical/list' ;
editor . registerNodeTransform ( ListItemNode , ( node ) => {
const parent = node . getParent ();
// ListItems must be inside ListNode
if ( ! $isListNode ( parent )) {
const list = $createListNode ( 'bullet' );
node . insertBefore ( list );
list . append ( node );
}
});
Synchronizing Properties
Keep related properties in sync:
editor . registerNodeTransform ( CustomNode , ( node ) => {
const children = node . getChildren ();
const childCount = children . length ;
// Keep count property synchronized
if ( node . getChildCount () !== childCount ) {
node . setChildCount ( childCount );
}
});
Instance Method
Define transforms directly in the node class:
class AutoUppercaseNode extends TextNode {
static getType () {
return 'auto-uppercase' ;
}
// Static transform method
static transform () : Transform < AutoUppercaseNode > | null {
return ( node : AutoUppercaseNode ) => {
const text = node . getTextContent ();
if ( text !== text . toUpperCase ()) {
node . setTextContent ( text . toUpperCase ());
}
};
}
// ... other methods
}
Via $config
Use the experimental $config API:
class CustomNode extends ElementNode {
$config () {
return this . config ( 'custom' , {
extends: ElementNode ,
$transform : ( node : CustomNode ) => {
// Transform logic
},
});
}
}
Preventing Infinite Loops
Check Before Mutating
Only mutate when necessary:
editor . registerNodeTransform ( TextNode , ( node ) => {
const text = node . getTextContent ();
const upper = text . toUpperCase ();
// ✅ Only update if different
if ( text !== upper ) {
node . setTextContent ( upper );
}
// ❌ This would loop forever:
// node.setTextContent(text.toUpperCase());
});
Use Markers
Mark nodes to prevent re-transformation:
const PROCESSED_NODES = new WeakSet ();
editor . registerNodeTransform ( TextNode , ( node ) => {
if ( PROCESSED_NODES . has ( node )) {
return ;
}
// Transform logic
const text = node . getTextContent ();
// ...
PROCESSED_NODES . add ( node );
});
Conditional Logic
Only transform under specific conditions:
editor . registerNodeTransform ( TextNode , ( node ) => {
const parent = node . getParent ();
// Only transform in code blocks
if ( ! $isCodeNode ( parent )) {
return ;
}
// Transform logic for code
});
Composition Context
Transforms skip nodes being composed via IME:
import { $getCompositionKey } from 'lexical' ;
editor . registerNodeTransform ( TextNode , ( node ) => {
const compositionKey = $getCompositionKey ();
// Lexical automatically skips this node if it's being composed
// But you can check manually:
if ( node . getKey () === compositionKey ) {
return ;
}
// Transform logic
});
Lexical automatically skips transforming nodes that are currently being composed to avoid interfering with IME input.
You can skip transforms for specific updates:
editor . update (
() => {
// Make changes without triggering transforms
const node = $getRoot (). getFirstChild ();
node ?. markDirty ();
},
{
skipTransforms: true ,
}
);
Transforms run frequently—keep them lightweight:
// ✅ Good - fast check
editor . registerNodeTransform ( TextNode , ( node ) => {
if ( node . getTextContent (). length > 1000 ) {
// Only process long text
}
});
// ❌ Bad - expensive operation
editor . registerNodeTransform ( TextNode , ( node ) => {
const text = node . getTextContent ();
// Complex regex on every keystroke
text . match ( /very-complex-regex-pattern/ g );
});
Batch Operations
Group related changes:
editor . registerNodeTransform ( ParagraphNode , ( node ) => {
const children = node . getChildren ();
// Batch mutations
const nodesToRemove : LexicalNode [] = [];
children . forEach ( child => {
if ( shouldRemove ( child )) {
nodesToRemove . push ( child );
}
});
// Single mutation
nodesToRemove . forEach ( n => n . remove ());
});
Only register transforms for nodes that need them:
// ✅ Good - specific node type
editor . registerNodeTransform ( HashtagNode , ( node ) => {
// Only runs for hashtags
});
// ❌ Bad - runs for all text
editor . registerNodeTransform ( TextNode , ( node ) => {
// Checks every TextNode
if ( node . getTextContent (). startsWith ( '#' )) {
// Hashtag logic
}
});
React Integration
Register transforms in React components:
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' ;
import { useEffect } from 'react' ;
function TransformPlugin () {
const [ editor ] = useLexicalComposerContext ();
useEffect (() => {
return editor . registerNodeTransform (
TextNode ,
( node ) => {
// Transform logic
}
);
}, [ editor ]);
return null ;
}
editor . registerNodeTransform ( TextNode , ( node ) => {
if ( __DEV__ ) {
console . log ( 'Transform on' , node . getKey (), node . getTextContent ());
}
// Transform logic
});
let transformCount = 0 ;
editor . registerNodeTransform ( TextNode , ( node ) => {
transformCount ++ ;
if ( transformCount > 50 ) {
console . warn ( 'Transform running many times!' );
}
// Transform logic
});
Best Practices
Make transforms idempotent
Avoid unnecessary mutations: editor . registerNodeTransform ( TextNode , ( node ) => {
const text = node . getTextContent ();
const formatted = formatText ( text );
// Only mutate if changed
if ( text !== formatted ) {
node . setTextContent ( formatted );
}
});
Ensure type safety when navigating the tree: editor . registerNodeTransform ( TextNode , ( node ) => {
const parent = node . getParent ();
if ( $isParagraphNode ( parent )) {
// Safe to use ParagraphNode methods
}
});
Type Signatures
type Transform < T extends LexicalNode > = ( node : T ) => void ;
class LexicalEditor {
registerNodeTransform < T extends LexicalNode >(
klass : Klass < T >,
listener : Transform < T >
) : () => void ;
}
class LexicalNode {
/**
* Override to define a static transform for this node class.
* Registered automatically when the node is registered.
*/
static transform () : Transform < LexicalNode > | null {
return null ;
}
}
Advanced Examples
URL Auto-Linking
import { $createLinkNode } from '@lexical/link' ;
const URL_REGEX = /https ? : \/\/ [ ^ \s ] + / g ;
editor . registerNodeTransform ( TextNode , ( node ) => {
const text = node . getTextContent ();
const matches = Array . from ( text . matchAll ( URL_REGEX ));
if ( matches . length === 0 ) return ;
matches . reverse (). forEach ( match => {
const [ url ] = match ;
const index = match . index ! ;
const endIndex = index + url . length ;
const [ before , urlText , after ] = node . splitText ( index , endIndex );
if ( urlText ) {
const linkNode = $createLinkNode ( url );
linkNode . append ( $createTextNode ( url ));
urlText . replace ( linkNode );
}
});
});
Smart Quotes
editor . registerNodeTransform ( TextNode , ( node ) => {
let text = node . getTextContent ();
let changed = false ;
// Replace straight quotes with smart quotes
const newText = text
. replace ( / \B " ( [ ^ " ] + ) " \B / g , ( match , content ) => {
changed = true ;
return `“ ${ content } ”` ;
})
. replace ( / \B ' ( [ ^ ' ] + ) ' \B / g , ( match , content ) => {
changed = true ;
return `‘ ${ content } ’` ;
});
if ( changed ) {
node . setTextContent ( newText );
}
});
import { $createHeadingNode } from '@lexical/rich-text' ;
editor . registerNodeTransform ( TextNode , ( node ) => {
const text = node . getTextContent ();
const parent = node . getParent ();
if ( ! $isParagraphNode ( parent )) return ;
if ( node !== parent . getFirstChild ()) return ;
const match = text . match ( / ^ ( # {1,6} ) \s / );
if ( ! match ) return ;
const level = match [ 1 ]. length as 1 | 2 | 3 | 4 | 5 | 6 ;
const heading = $createHeadingNode ( `h ${ level } ` );
// Move all children
parent . getChildren (). forEach ( child => {
heading . append ( child );
});
// Remove the # markers
const firstChild = heading . getFirstChild ();
if ( $isTextNode ( firstChild )) {
const content = firstChild . getTextContent (). replace ( / ^ # {1,6} \s / , '' );
firstChild . setTextContent ( content );
}
parent . replace ( heading );
});
Nodes - Understanding the node system
Updates - When transforms run in the update cycle
Editor - Registering transforms