Skip to main content
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.

What Are Transforms?

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');
  }
});

Registering Transforms

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();

Transform Signature

type Transform<T extends LexicalNode> = (node: T) => void;

editor.registerNodeTransform<T extends LexicalNode>(
  nodeClass: Klass<T>,
  transform: Transform<T>
): () => void;

When Transforms Run

Transforms run during the update cycle:

Execution Order

  1. Leaf nodes first: TextNode, LineBreakNode, etc.
  2. Element nodes second: ParagraphNode, HeadingNode, etc.
  3. Root node last: RootNode transforms run after all descendants
  4. 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.

Common Transform Patterns

Auto-Formatting

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);
  }
});

Transforms on Custom Nodes

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.

Skipping Transforms

You can skip transforms for specific updates:
editor.update(
  () => {
    // Make changes without triggering transforms
    const node = $getRoot().getFirstChild();
    node?.markDirty();
  },
  {
    skipTransforms: true,
  }
);

Performance Considerations

Keep Transforms Fast

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());
});

Limit Transform Scope

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;
}

Debugging Transforms

Log Transform Execution

editor.registerNodeTransform(TextNode, (node) => {
  if (__DEV__) {
    console.log('Transform on', node.getKey(), node.getTextContent());
  }
  
  // Transform logic
});

Track Transform Count

let transformCount = 0;

editor.registerNodeTransform(TextNode, (node) => {
  transformCount++;
  
  if (transformCount > 50) {
    console.warn('Transform running many times!');
  }
  
  // Transform logic
});

Best Practices

Transforms should produce the same result when run multiple times:
// ✅ Good - idempotent
editor.registerNodeTransform(TextNode, (node) => {
  const text = node.getTextContent();
  if (text !== text.trim()) {
    node.setTextContent(text.trim());
  }
});

// ❌ Bad - not idempotent
editor.registerNodeTransform(TextNode, (node) => {
  const text = node.getTextContent();
  node.setTextContent(text + '!'); // Keeps adding!
});
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
  }
});
Remember that leaf nodes transform before elements:
// Runs first
editor.registerNodeTransform(TextNode, (node) => {
  // Leaf transform
});

// Runs second
editor.registerNodeTransform(ParagraphNode, (node) => {
  // Element transform (can see text changes)
});
Always remove transforms when done:
const removeTransform = editor.registerNodeTransform(...);

// Later
removeTransform();

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);
  }
});

Markdown Headers

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

Build docs developers (and LLMs) love