Skip to main content
Common questions about working with Lexical, from architecture fundamentals to advanced patterns.

General Questions

Lexical is an extensible text editor framework that provides excellent reliability, accessibility, and performance. It’s framework-agnostic at its core with official React bindings.Key features:
  • Framework-agnostic core with React bindings
  • Immutable state model with time-travel capabilities
  • Built-in accessibility and WCAG compliance
  • Plugin-based architecture
  • Collaborative editing via Yjs
  • Rich content support (tables, lists, code, images)
  • TypeScript and Flow type definitions
Lexical takes a unique approach:Immutable State Model: EditorState is completely immutable and serializable, enabling time-travel debugging and reliable undo/redo.Double-Buffering: Updates are batched and applied via a work-in-progress state that’s diffed against the current state.Framework Agnostic: Core is pure JavaScript; React is just one integration option.$ Function Convention: Enforces update context (like React hooks but for synchronous context).Node Immutability: All nodes are recursively frozen after reconciliation. Mutations automatically clone nodes.
Yes! Lexical’s core is framework-agnostic. You can use it with vanilla JavaScript, Svelte, Vue, or any other framework.See the vanilla-js examples for implementation patterns.
Lexical supports modern browsers:
  • Chrome 86+
  • Firefox 115+
  • Safari 15+
  • Edge 86+
See Supported Browsers for details.

Architecture & Concepts

Functions prefixed with $ (e.g., $getRoot(), $getSelection()) can only be called within:
  • editor.update(() => {...}) - for mutations
  • editor.read(() => {...}) - for read-only access
  • Node transforms and command handlers (which have implicit update context)
This enforces proper update context, similar to React hooks but for synchronous execution context instead of call order.
// ❌ Wrong - not in update context
const root = $getRoot();

// ✅ Correct
editor.update(() => {
  const root = $getRoot();
  // ... mutations
});

// ✅ Also correct
editor.read(() => {
  const root = $getRoot();
  // ... read-only access
});
EditorState is the immutable data model representing your editor’s content. It contains:
  • A node tree (hierarchical structure)
  • Selection object (cursor/selection state)
  • Everything needed to reconstruct the editor
Why immutable?
  • Enables reliable undo/redo
  • Time-travel debugging
  • Predictable updates
  • Easy serialization to JSON
  • Safe concurrent reads
Updates create a new EditorState via double-buffering:
  1. Current state is cloned as work-in-progress
  2. Your mutations modify the WIP state
  3. DOM reconciler diffs and applies changes
  4. New immutable state becomes current
Lexical uses a synchronous update model:editor.update():
  • Clones current EditorState
  • Applies your mutations to work-in-progress
  • Batches multiple synchronous updates
  • Runs transforms
  • Reconciles DOM
  • Fires listeners
editor.read():
  • Flushes any pending updates first
  • Provides read-only access to reconciled state
  • Cannot make mutations
Key rules:
  • Inside update() you see pending state (pre-transform)
  • editor.getEditorState().read() always uses latest reconciled state
  • Don’t nest reads in updates or vice versa (except read at end of update)
Node transforms are functions that run automatically when nodes of a specific type change during an update.
editor.registerNodeTransform(TextNode, (node) => {
  // This runs whenever any TextNode changes
  const text = node.getTextContent();
  if (text.includes(':)')) {
    // Transform smiley to emoji
  }
});
Use cases:
  • Text replacements (markdown shortcuts, emoji)
  • Validation and normalization
  • Automatic formatting
  • Custom node behaviors
Transforms have implicit update context, so you can use $ functions directly.
Commands are Lexical’s primary communication mechanism.Creating:
const MY_COMMAND = createCommand<PayloadType>();
Dispatching:
editor.dispatchCommand(MY_COMMAND, payload);
Handling:
editor.registerCommand(
  MY_COMMAND,
  (payload) => {
    // Handle command
    return true; // Stop propagation
  },
  COMMAND_PRIORITY_NORMAL
);
Handlers propagate by priority until one stops it. Multiple handlers can process the same command.

Working with Nodes

Extend one of the base node classes:
import { ElementNode } from 'lexical';

export class MyCustomNode extends ElementNode {
  static getType(): string {
    return 'my-custom';
  }
  
  static clone(node: MyCustomNode): MyCustomNode {
    return new MyCustomNode(node.__key);
  }
  
  createDOM(): HTMLElement {
    return document.createElement('div');
  }
  
  updateDOM(): boolean {
    return false; // true if DOM needs update
  }
  
  static importJSON(serializedNode: any): MyCustomNode {
    return $createMyCustomNode();
  }
  
  exportJSON(): any {
    return {
      ...super.exportJSON(),
      type: 'my-custom',
    };
  }
}

export function $createMyCustomNode(): MyCustomNode {
  return new MyCustomNode();
}
Register with editor:
const config = {
  nodes: [MyCustomNode],
};
TextNode:
  • Inline text content
  • Supports formatting (bold, italic, etc.)
  • Examples: regular text, links, mentions
ElementNode:
  • Block or container elements
  • Can have children (other nodes)
  • Examples: paragraphs, headings, lists, divs
DecoratorNode:
  • Custom React/DOM components
  • Non-text content
  • Examples: images, videos, embeds, widgets
Choose based on whether you need text formatting, children, or custom rendering.
Nodes are immutable (frozen) after reconciliation. To modify a node:
editor.update(() => {
  const node = $getNodeByKey(key);
  const writableNode = node.getWritable();
  writableNode.setCustomProperty(value);
});
Or use convenience methods that automatically call getWritable():
editor.update(() => {
  const node = $getNodeByKey(key);
  node.setCustomProperty(value); // Automatically gets writable clone
});
This ensures all mutations go through the update cycle properly.
Every node has a unique runtime key. All versions of a logical node (across EditorState snapshots) share the same key.Important:
  • Keys are runtime-only, not serialized
  • Use keys to identify nodes within an update
  • Node methods automatically resolve to latest version via key
  • Don’t store node references across update boundaries
editor.update(() => {
  const node = $getNodeByKey(someKey);
  // 'node' is always the latest version from active EditorState
});

Common Patterns

Use registerUpdateListener:
editor.registerUpdateListener(({ editorState, dirtyElements, dirtyLeaves }) => {
  editorState.read(() => {
    // Read the current state
    const root = $getRoot();
    const selection = $getSelection();
  });
});
All register* methods return cleanup functions:
const unregister = editor.registerUpdateListener(...);
// Later:
unregister();
In React, use useEffect:
useEffect(() => {
  return editor.registerUpdateListener(...);
}, [editor]);
Use text match transformers from @lexical/markdown:
import { registerMarkdownShortcuts } from '@lexical/markdown';
import { TRANSFORMERS } from '@lexical/markdown';

registerMarkdownShortcuts(editor, TRANSFORMERS);
Or create custom transformers:
const EMOJI_TRANSFORMER = {
  type: 'text-match',
  importRegExp: /:\)/,
  replace: (textNode) => {
    textNode.setTextContent('😊');
  },
};
Lexical handles clipboard automatically, but you can customize:
import { registerRichText } from '@lexical/rich-text';

// Rich text includes clipboard handling
registerRichText(editor);
For custom handling:
editor.registerCommand(
  PASTE_COMMAND,
  (event: ClipboardEvent) => {
    // Custom paste logic
    return true; // Handled
  },
  COMMAND_PRIORITY_HIGH
);
Use the history plugin:React:
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';

<HistoryPlugin />
Vanilla:
import { registerHistory } from '@lexical/history';

registerHistory(editor, createEmptyHistoryState(), 1000);
Dispatch commands:
editor.dispatchCommand(UNDO_COMMAND, undefined);
editor.dispatchCommand(REDO_COMMAND, undefined);

Performance & Best Practices

Best practices:
  1. Batch updates: Multiple synchronous editor.update() calls are automatically batched
  2. Avoid unnecessary reads: editor.read() flushes pending updates
  3. Use transforms wisely: They run on every relevant node change
  4. Debounce listeners: Don’t do heavy work in every update listener
  5. Lazy load plugins: Only register plugins you need
// ✅ Good - batched automatically
editor.update(() => {
  node1.setText('a');
  node2.setText('b');
});

// ❌ Bad - forces multiple reconciliations
editor.update(() => node1.setText('a'));
editor.update(() => node2.setText('b'));
Use plugins for:
  • UI components
  • Event handlers
  • Command handlers
  • Lifecycle management
Use transforms for:
  • Automatic node mutations
  • Text replacements
  • Validation
  • Format normalization
Plugins can register transforms, commands, and listeners. Transforms are more focused on automatic node manipulation.
Built-in tools:
  1. Tree View Plugin: Visualize node structure
    import { TreeViewPlugin } from '@lexical/react/LexicalTreeViewPlugin';
    
  2. DevTools: Browser extension for inspecting EditorState
  3. State Inspection:
    editor.getEditorState().read(() => {
      console.log($getRoot().getTextContent());
      console.log($getSelection());
    });
    
  4. Update Tags: Track update sources
    editor.update(() => {
      // mutations
    }, { tag: 'my-custom-update' });
    

Still Have Questions?

Discord Community

Join our Discord for real-time help and discussions

GitHub Discussions

Ask questions and share knowledge

API Reference

Explore detailed API documentation

Examples

Browse working examples and patterns

Build docs developers (and LLMs) love