web-editor-lexical · git:20260709.68e20a4 · 2026-07-09 · sha256 49e7d235a9495447

web-editor-lexical git:20260709.68e20a4A

Immutable. This exact content is served forever at /api/v1/blob/49e7d235a9495447.

---
name: web-editor-lexical
description: Extensible text editor framework by Meta
---

# Lexical Editor Patterns

> **Quick Guide:** Lexical is a lightweight (22kb min+gzip) extensible text editor framework. Use `LexicalComposer` for React setup with plugins as child components. Extend via the node system (ElementNode, TextNode, DecoratorNode), command system (createCommand + priorities), and transforms. EditorState is immutable -- all mutations happen inside `editor.update()`. Use `$`-prefixed functions only inside update/read closures. **Current: v0.42.x (pre-1.0)**

---

<critical_requirements>

## CRITICAL: Before Using This Skill

> **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)

**(You MUST call `$`-prefixed functions (`$getRoot`, `$getSelection`, `$createTextNode`) ONLY inside `editor.update()` or `editor.read()` closures -- calling them outside throws runtime errors)**

**(You MUST register custom nodes in the `nodes` array of `initialConfig` -- unregistered nodes cause silent failures or runtime errors)**

**(You MUST return a cleanup function from `useEffect` when registering commands, transforms, or listeners -- Lexical register methods return unsubscribe functions)**

**(You MUST include preconditions in transforms to prevent infinite loops -- a transform that unconditionally modifies its target node re-triggers itself)**

</critical_requirements>

---

**Auto-detection:** Lexical, lexical, @lexical/react, @lexical/rich-text, @lexical/list, @lexical/code, @lexical/link, @lexical/html, @lexical/headless, LexicalComposer, EditorState, LexicalNode, ElementNode, TextNode, DecoratorNode, createCommand, dispatchCommand, registerCommand, COMMAND_PRIORITY, $getRoot, $getSelection, $createParagraphNode, $createTextNode, RichTextPlugin, OnChangePlugin, HistoryPlugin, useLexicalComposerContext, editor.update, editor.read, registerNodeTransform, exportJSON, importJSON, exportDOM, importDOM, NodeState, createState

**When to use:**

- Building rich text editors with custom formatting and embedded content
- Creating editors with custom node types (mentions, embeds, code blocks)
- Implementing collaborative editing with operational transforms
- Building structured content editors (not just plain text)

**When NOT to use:**

- Plain text inputs without formatting (use a standard `<textarea>`)
- Simple markdown editing without live preview (use a textarea with markdown parsing)
- Editors that need pure decorations without document mutation (Lexical decorator nodes mutate content)

**Key patterns covered:**

- React setup with LexicalComposer, plugins, and initialConfig
- Node system: ElementNode, TextNode, DecoratorNode, custom nodes
- Command system: createCommand, priorities, dispatching, propagation
- Transforms for automatic node mutations
- EditorState immutability and the update lifecycle
- JSON and HTML serialization

**Detailed Resources:**

- [examples/core.md](examples/core.md) - Editor setup, plugins, commands, transforms
- [examples/custom-nodes.md](examples/custom-nodes.md) - Custom ElementNode, TextNode, DecoratorNode, NodeState API
- [examples/serialization.md](examples/serialization.md) - JSON/HTML serialization, import/export, headless usage
- [reference.md](reference.md) - Decision frameworks, command priority table, anti-patterns

---

<philosophy>

## Philosophy

Lexical is an editor framework, not a batteries-included editor. The core is intentionally minimal -- it provides the node tree, selection, reconciler, command system, and update lifecycle. Everything else (toolbars, formatting, lists, links, embeds) is a plugin.

**Key architectural principles:**

- **Immutable EditorState:** The editor maintains a frozen state snapshot. Mutations happen inside `editor.update()` closures that clone the state, apply changes, then reconcile to DOM.
- **`$`-function convention:** Functions prefixed with `$` (like `$getRoot()`, `$getSelection()`) must run inside `editor.update()` or `editor.read()` closures -- similar to React hooks requiring a component context.
- **Plugin = React component:** In the React binding, a plugin is a React component rendered as a child of `<LexicalComposer>`. It accesses the editor via `useLexicalComposerContext()` and registers commands/transforms/listeners in `useEffect`.
- **Command-driven architecture:** User interactions and plugin communication flow through typed commands with priority-based listeners, enabling plugins to intercept or augment behavior.
- **Node-driven content model:** Content is a tree of typed nodes. Custom content types (mentions, embeds, polls) are custom node classes.

**When to use Lexical:**

- Rich text editing with custom formatting and embedded content
- Content editors requiring structured output (not just HTML strings)
- Editors needing accessibility and screen reader support
- Applications requiring server-side rendering or headless processing of editor content

**When NOT to use Lexical:**

- Simple text inputs (a `<textarea>` is simpler and lighter)
- Editors needing pure decorations that don't affect document content
- Projects requiring a stable 1.0 API (Lexical is pre-1.0, APIs may change)

</philosophy>

---

<patterns>

## Core Patterns

### Pattern 1: React Editor Setup

The minimal setup uses `LexicalComposer` wrapping plugin components. Each plugin is a React component that accesses the editor via context.

```typescript
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";

const EDITOR_NAMESPACE = "MyEditor";

const theme = {
  paragraph: "editor-paragraph",
  text: {
    bold: "editor-text-bold",
    italic: "editor-text-italic",
  },
};

function onError(error: Error) {
  console.error(error);
}

const initialConfig = {
  namespace: EDITOR_NAMESPACE,
  theme,
  onError,
  nodes: [], // Register custom nodes here
};

export function Editor() {
  return (
    <LexicalComposer initialConfig={initialConfig}>
      <RichTextPlugin
        contentEditable={<ContentEditable className="editor-input" />}
        ErrorBoundary={LexicalErrorBoundary}
      />
      <HistoryPlugin />
    </LexicalComposer>
  );
}
```

**Why good:** Plugins compose as children, initialConfig centralizes node registration and theming, error boundary catches update errors gracefully

See [examples/core.md](examples/core.md) for the full setup with OnChangePlugin, AutoFocusPlugin, and custom plugins.

---

### Pattern 2: The `$`-Function Convention and Update Lifecycle

All state reads and mutations use `$`-prefixed functions inside `editor.update()` (mutable) or `editor.read()` (read-only) closures. This ensures state consistency and prevents stale reads.

```typescript
import { $getRoot, $createParagraphNode, $createTextNode } from "lexical";

// Writing: editor.update() clones state, applies changes, reconciles DOM
editor.update(() => {
  const root = $getRoot();
  const paragraph = $createParagraphNode();
  const text = $createTextNode("Hello world");
  paragraph.append(text);
  root.append(paragraph);
});

// Reading: editor.read() provides safe read-only access
editor.read(() => {
  const root = $getRoot();
  const textContent = root.getTextContent();
});
```

**Why good:** Immutable state model prevents race conditions, `$` prefix signals context requirement (like React hooks), update batching minimizes DOM reconciliation

**Gotcha:** `$`-functions called outside update/read closures throw runtime errors. There is no compile-time check.

---

### Pattern 3: Command System

Commands are the communication bus between plugins, toolbars, and the editor core. Create typed commands, dispatch them from UI, and register listeners with priorities.

```typescript
import {
  createCommand,
  COMMAND_PRIORITY_EDITOR,
  COMMAND_PRIORITY_LOW,
  type LexicalCommand,
} from "lexical";

// Create a typed command
export const INSERT_IMAGE_COMMAND: LexicalCommand<{
  src: string;
  alt: string;
}> = createCommand("INSERT_IMAGE_COMMAND");

// Dispatch from toolbar or UI
editor.dispatchCommand(INSERT_IMAGE_COMMAND, {
  src: "/image.png",
  alt: "Photo",
});
```

**Priority levels** (higher number = runs first, can intercept):

| Priority                    | Value | Use case                           |
| --------------------------- | ----- | ---------------------------------- |
| `COMMAND_PRIORITY_CRITICAL` | 4     | Emergency overrides                |
| `COMMAND_PRIORITY_HIGH`     | 3     | Table navigation, critical plugins |
| `COMMAND_PRIORITY_NORMAL`   | 2     | Standard plugin behavior           |
| `COMMAND_PRIORITY_LOW`      | 1     | Default for most plugins           |
| `COMMAND_PRIORITY_EDITOR`   | 0     | Base editor behavior               |

**Return `true`** from a listener to stop propagation to lower-priority listeners.

See [examples/core.md](examples/core.md) for the full command registration pattern with cleanup.

---

### Pattern 4: Custom Plugins (React)

A plugin is a React component that registers commands, transforms, or listeners via `useLexicalComposerContext`. Always return cleanup functions from `useEffect`.

```typescript
import { useEffect } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
  COMMAND_PRIORITY_LOW,
  FORMAT_TEXT_COMMAND,
  type TextFormatType,
} from "lexical";

export function ToolbarPlugin() {
  const [editor] = useLexicalComposerContext();

  const handleFormat = (format: TextFormatType) => {
    editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
  };

  return (
    <div className="toolbar">
      <button onClick={() => handleFormat("bold")} type="button">
        Bold
      </button>
      <button onClick={() => handleFormat("italic")} type="button">
        Italic
      </button>
    </div>
  );
}
```

**Why good:** Plugin accesses editor through context hook, dispatches built-in FORMAT_TEXT_COMMAND, renders null or UI as needed

See [examples/core.md](examples/core.md) for plugins that register commands with useEffect cleanup.

---

### Pattern 5: Node Transforms

Transforms automatically mutate nodes when conditions are met. They run before DOM reconciliation, making them the most efficient way to react to content changes.

```typescript
import { TextNode } from "lexical";

// Transform: auto-capitalize first letter of paragraphs
editor.registerNodeTransform(TextNode, (textNode) => {
  const text = textNode.getTextContent();
  // CRITICAL: Precondition prevents infinite loop
  if (text.length > 0 && text[0] !== text[0].toUpperCase()) {
    textNode.setTextContent(text[0].toUpperCase() + text.slice(1));
  }
});
```

**Why preconditions matter:** Without the check, `setTextContent` marks the node dirty, re-triggering the transform infinitely.

**Transform execution order:** Leaf nodes first, then element nodes, then RootNode. Multiple transforms produce a single DOM reconciliation.

See [examples/core.md](examples/core.md) for transform registration with cleanup and use cases.

---

### Pattern 6: Custom Nodes

Lexical provides three extendable base nodes for custom content types:

| Base Node          | Purpose                                       | Key method                       |
| ------------------ | --------------------------------------------- | -------------------------------- |
| `ElementNode`      | Container nodes (blockquote, callout)         | `createDOM()`, `updateDOM()`     |
| `TextNode`         | Styled text variants (colored text, mentions) | `createDOM()`, `updateDOM()`     |
| `DecoratorNode<T>` | Embedded components (images, videos, polls)   | `decorate()` returns a component |

Every custom node requires:

1. `static getType()` -- unique string identifier
2. `static clone(node)` -- create copy for state snapshots
3. `createDOM()` -- return the HTMLElement representation
4. `updateDOM()` -- return `false` if existing DOM can be reused
5. `exportJSON()` / `static importJSON()` -- serialization
6. Registration in `initialConfig.nodes`

```typescript
import { DecoratorNode } from "lexical";
import type { LexicalNode, NodeKey, EditorConfig } from "lexical";

export class ImageNode extends DecoratorNode<JSX.Element> {
  __src: string;
  __alt: string;

  static getType(): string {
    return "image";
  }

  static clone(node: ImageNode): ImageNode {
    return new ImageNode(node.__src, node.__alt, node.__key);
  }

  constructor(src: string, alt: string, key?: NodeKey) {
    super(key);
    this.__src = src;
    this.__alt = alt;
  }

  createDOM(_config: EditorConfig): HTMLElement {
    return document.createElement("div");
  }

  updateDOM(): boolean {
    return false;
  }

  decorate(): JSX.Element {
    return <img src={this.__src} alt={this.__alt} />;
  }
}
```

**Property convention:** Prefix private properties with `__` (double underscore) to prevent minification issues. All properties must be JSON-serializable.

See [examples/custom-nodes.md](examples/custom-nodes.md) for complete ElementNode, TextNode, DecoratorNode examples with serialization and the NodeState API.

---

### Pattern 7: EditorState Serialization

Lexical supports JSON (preferred for persistence) and HTML (for display or interop).

```typescript
import { $generateHtmlFromNodes } from "@lexical/html";

// JSON: lossless round-trip
const json = editor.getEditorState().toJSON();
const jsonString = JSON.stringify(json);

// Restore from JSON
const editorState = editor.parseEditorState(jsonString);
editor.setEditorState(editorState);

// HTML: for rendering or export
editor.read(() => {
  const html = $generateHtmlFromNodes(editor, null);
});
```

**JSON vs HTML:** JSON preserves the full node tree and is the recommended format for persistence. HTML is lossy (loses custom node properties) but useful for display or email content.

See [examples/serialization.md](examples/serialization.md) for complete import/export patterns, HTML-to-Lexical conversion, and headless editor usage.

</patterns>

---

<decision_framework>

## Decision Framework

### Which Node Type to Extend

```
Does your content contain child nodes?
├─ YES → ElementNode (paragraphs, blockquotes, callouts)
└─ NO → Is it text with special formatting or behavior?
    ├─ YES → TextNode (colored text, mentions)
    └─ NO → Is it an embedded component (image, video, widget)?
        ├─ YES → DecoratorNode (renders arbitrary UI)
        └─ NO → Re-evaluate: most content fits one of the above
```

### Plugin vs Transform vs Listener

```
Need to react to content changes?
├─ YES → Does the reaction modify nodes?
│   ├─ YES → Transform (most efficient, runs before DOM reconciliation)
│   └─ NO → Update listener (read-only, runs after reconciliation)
└─ NO → Need to handle user actions or toolbar clicks?
    ├─ YES → Command (typed, priority-based, interceptable)
    └─ NO → Listener (registerUpdateListener for state observation)
```

### Command Priority Selection

```
Is this the base editor behavior?
├─ YES → COMMAND_PRIORITY_EDITOR (0)
└─ NO → Is this a standard plugin?
    ├─ YES → COMMAND_PRIORITY_LOW (1) or COMMAND_PRIORITY_NORMAL (2)
    └─ NO → Must it override other plugins (e.g., table navigation)?
        ├─ YES → COMMAND_PRIORITY_HIGH (3)
        └─ NO → Emergency override only?
            └─ YES → COMMAND_PRIORITY_CRITICAL (4)
```

</decision_framework>

---

<red_flags>

## RED FLAGS

**High Priority Issues:**

- Calling `$`-prefixed functions outside `editor.update()` or `editor.read()` -- causes runtime errors with no compile-time warning
- Missing node registration in `initialConfig.nodes` -- custom nodes silently fail or throw when the editor encounters them
- Transforms without preconditions -- unconditional mutations retrigger the transform infinitely, freezing the editor
- Using `editor.update()` inside an update listener to modify state -- breaks undo/redo history and causes extra renders; use transforms instead
- Forgetting `useEffect` cleanup for register calls -- leaks listeners, causes stale references after component unmount

**Medium Priority Issues:**

- Direct DOM manipulation instead of using the node/command system -- bypasses the reconciler, causes state-DOM desync
- Storing non-JSON-serializable values in node properties (functions, Maps, Sets) -- breaks serialization silently
- Using `editor.setEditorState()` without cloning -- can cause unexpected focus changes; use `editorState.clone(null)` to prevent auto-focus
- Naming custom node `getType()` with a non-unique string -- collides with other nodes, causes deserialization failures
- Using `new MyNode()` directly instead of `$createMyNode()` factory -- bypasses the node replacement system (`$applyNodeReplacement`)
- Single underscore node properties (`_value` instead of `__value`) -- may be mangled by minifiers, breaking node access
- `console.log` in `onError` callback with no rethrow -- silently swallows editor errors; rethrow or send to error tracking

**Gotchas & Edge Cases:**

- `editor.update()` batches synchronously but reconciles asynchronously -- use `{ discrete: true }` option when you need synchronous DOM commit (e.g., before reading DOM measurements)
- Node property names must use `__` prefix convention -- single underscore properties may be mangled by minifiers
- `DecoratorNode.decorate()` returns a component that Lexical renders outside the normal React tree -- state management in decorator components needs care
- The `onError` callback in `initialConfig` receives errors from update closures -- if you don't rethrow, Lexical tries to recover gracefully
- `TextNode` modes: `"token"` makes text immutable (like a chip), `"segmented"` deletes word-by-word
- CSS `transition` does not work for animations based on node removal -- Lexical reconciles by removing DOM nodes, not hiding them
- The NodeState API (v0.26+) is experimental -- APIs may change without extended deprecation

</red_flags>

---

<critical_reminders>

## CRITICAL REMINDERS

> **All code must follow project conventions in CLAUDE.md**

**(You MUST call `$`-prefixed functions (`$getRoot`, `$getSelection`, `$createTextNode`) ONLY inside `editor.update()` or `editor.read()` closures -- calling them outside throws runtime errors)**

**(You MUST register custom nodes in the `nodes` array of `initialConfig` -- unregistered nodes cause silent failures or runtime errors)**

**(You MUST return a cleanup function from `useEffect` when registering commands, transforms, or listeners -- Lexical register methods return unsubscribe functions)**

**(You MUST include preconditions in transforms to prevent infinite loops -- a transform that unconditionally modifies its target node re-triggers itself)**

**Failure to follow these rules will cause runtime errors, memory leaks, frozen editors, and broken undo/redo history.**

</critical_reminders>