Claude Code with Lexical: Editor State Without the React Footguns
Why Lexical without CLAUDE.md ships editors with corrupt state
Lexical is the rich-text framework that Meta built after burning out on contentEditable across Facebook, Messenger, and WhatsApp. It is built around an immutable editor state, a transaction model, and a strict separation between reads and writes. The selling point is performance: a million-character document stays at 60fps because the editor only re-renders the dirty subtrees. The cost is discipline: Lexical refuses operations that violate its state model, and the resulting errors are not always obvious.
Claude Code does not know any of this by default. It generates editor.getEditorState().getRoot().getChildren() outside an editor.update() block, the call returns nodes from a snapshot the editor has already disposed, and the next reference throws "EditorState is read-only outside of a read or update callback." The most common Claude defaults that break Lexical in production: state reads outside editorState.read() so the snapshot is detached, state mutations outside editor.update() so the changes never apply, custom nodes without symmetric importDOM and exportDOM so copy-paste round trips destroy the content, decorator nodes without React.memo so every keystroke re-renders the decorator tree, command listeners registered with magic-number priorities so newer plugins quietly override older ones, history plugin defaults that coalesce too aggressively for the use case, and editor instances created inside the React component body so strict-mode double-mount creates two editors that fight.
This guide covers the CLAUDE.md configuration that locks Claude Code into Lexical's state-discipline model: reads inside editorState.read(), writes inside editor.update(), custom nodes scaffolded with importDOM, exportDOM, importJSON, and exportJSON in the same file, decorator nodes wrapped in React.memo with stable selectors, commands registered with the COMMAND_PRIORITY_* constants, history plugin configured with explicit delay and granularity, and the editor created via createEditor outside the component body or memoised inside it. For the React component patterns Lexical depends on, Claude Code with React covers the rendering model that pairs cleanly with decorator nodes.
The Lexical CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Lexical integration it needs to declare: the Lexical version line, the React plugins enabled, the custom node list, the command priority policy, the decorator authoring pattern, the history configuration, and the hard rules that block the mistakes Claude makes most often.
# Lexical rules
## Stack
- lexical ^0.x with @lexical/react ^0.x bindings
- @lexical/rich-text for paragraphs, headings, quotes
- @lexical/list, @lexical/code, @lexical/link for canonical blocks
- @lexical/history for undo / redo
- @lexical/markdown for markdown shortcuts (optional)
- Editor created OUTSIDE the React component body, or memoised inside it
## Project structure
- src/editor/ , editor configuration and theme
- src/editor/nodes/ , custom node definitions
- src/editor/plugins/ , LexicalComposer plugins (commands, decoration)
- src/editor/decorators/ , React components rendered as DecoratorNodes
- src/editor/serialise/ , JSON and HTML serialisation helpers
- src/editor/theme.ts , class name map for node types
## State access (MANDATORY)
- READ state inside editor.getEditorState().read(() => { ... })
- WRITE state inside editor.update(() => { ... })
- NEVER call $getRoot, $getSelection, $getNodeByKey OUTSIDE a read or update
- NEVER hold a node reference across read/update boundaries
## Custom nodes (MANDATORY for every custom node)
- extends ElementNode | TextNode | DecoratorNode (pick the right base)
- static getType(): unique string matching no built-in or other custom node
- static clone(node): returns a copy with the same key
- importJSON(json): MUST mirror exportJSON, both symmetric
- importDOM(): static method returning DOM conversion map
- exportDOM(): MUST produce HTML that importDOM can read back
- $createXNode and $isXNode helpers for selection and traversal
## Decorator nodes (when the node renders a React component)
- decorate(editor, config) returns React element
- Component wrapped in React.memo
- NEVER read editor state inside the component, pass data via node attributes
- NEVER subscribe to editor inside the component, use useLexicalNodeSelection
- ALWAYS use editor.update() inside event handlers that mutate the node
## Command priorities (MANDATORY)
- Use the constants COMMAND_PRIORITY_EDITOR, _LOW, _NORMAL, _HIGH, _CRITICAL
- NEVER use magic numbers (0-4) for priorities
- Return true from the handler to STOP propagation, false to CONTINUE
- Register in a plugin's useEffect with cleanup that calls the unregister
## History plugin
- Configure delay and granularity explicitly per editor use case
- For a comments box: delay 300, merge small edits
- For a long-form editor: delay 1000, separate by paragraph boundaries
- NEVER ship without HistoryPlugin if the editor is user-editable
## Hard rules
- NEVER access editor state outside read() or update()
- NEVER mutate a node outside an update() block
- NEVER hold a $get* result across the update callback boundary
- NEVER create the editor inside the React component body without useMemo
- NEVER skip importDOM on a custom node (copy-paste destroys it)
- NEVER use magic numbers for command priorities
- NEVER mix React state with editor state for the document content
- ALWAYS unregister command listeners in the cleanup function
- ALWAYS strip importDOM result if the DOM is from a non-trusted source
Five rules here prevent the majority of incidents Claude generates without them.
The state access rule prevents the most common Lexical crash. The editor state is immutable, and Lexical hands you a snapshot only when you ask for one inside read() or update(). Outside those callbacks the state object is either stale or null, and $getRoot() throws. Claude reaches for the snapshot in event handlers and effects without the wrapper because that is what feels idiomatic. The rule "READ inside read(), WRITE inside update()" forces the wrapper to be the default.
The custom node symmetry rule prevents the silent content loss on copy-paste. Lexical serialises nodes to JSON for internal state, to HTML for the clipboard, and to DOM elements for paste import. Without importJSON matching exportJSON and importDOM matching exportDOM, the node renders in the editor but cannot survive a round trip. The rule "importJSON MUST mirror exportJSON" makes the symmetry explicit, so Claude writes both halves at the same time.
The decorator memoisation rule prevents the typing lag that hits decorator-heavy editors. A Slack-style mention picker rendered as a DecoratorNode re-renders on every keystroke unless the component is memoised. With 30 mentions in a document, that is 30 React subtrees re-rendering on every character. The rule "Component wrapped in React.memo" makes the decorator a leaf node from React's perspective, so it only re-renders when its node attributes change.
The command priority rule prevents the most confusing class of plugin conflict. Lexical's command bus is global, and listeners run in priority order. If three plugins all register a handler for KEY_ENTER_COMMAND with priority 0, the order is registration-order-dependent and breaks when a plugin is added. The COMMAND_PRIORITY_* constants give every listener a named place in the chain, so a "lower priority" plugin lets a "higher priority" one run first.
The editor creation rule prevents the React 18 strict-mode double-mount disaster. createEditor() inside the component body creates a new editor on every render. Strict mode mounts twice, so the second mount sees a fresh editor and the user's first keystrokes vanish. The rule "outside the body or memoised" makes the editor identity stable across renders.
Install and editor setup
Install Lexical and the React bindings:
pnpm add lexical @lexical/react
Add the canonical plugins:
pnpm add @lexical/rich-text @lexical/list @lexical/link @lexical/history
Create the editor with the stable composer pattern:
// src/editor/Editor.tsx
'use client';
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 { ListPlugin } from '@lexical/react/LexicalListPlugin';
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
import { ListItemNode, ListNode } from '@lexical/list';
import { LinkNode } from '@lexical/link';
import { MentionNode } from './nodes/MentionNode';
import { MentionsPlugin } from './plugins/MentionsPlugin';
import { OnChangePlugin } from './plugins/OnChangePlugin';
import { editorTheme } from './theme';
type Props = {
initialJSON?: string;
onChange: (json: string) => void;
};
const editorConfig = {
namespace: 'app',
theme: editorTheme,
nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, MentionNode],
onError(error: Error) {
console.error('Lexical error:', error);
},
};
export function Editor({ initialJSON, onChange }: Props) {
return (
<LexicalComposer
initialConfig={{
...editorConfig,
editorState: initialJSON,
}}
>
<RichTextPlugin
contentEditable={<ContentEditable className="editor-input" />}
placeholder={<div className="editor-placeholder">Write something</div>}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<ListPlugin />
<LinkPlugin />
<MentionsPlugin />
<OnChangePlugin onChange={onChange} />
</LexicalComposer>
);
}
Three things in this snippet that the CLAUDE.md rule enforces. The editorConfig lives at module scope so the node list and theme are stable references. The error boundary catches schema or update errors so the editor surface degrades to read-only instead of crashing the page. The OnChangePlugin is a separate component that subscribes to editor changes, instead of binding the editor state to React state in the parent.
Custom nodes with symmetric serialisation
A custom node is the unit of Lexical extensibility. A correctly-authored node has a schema declaration via the class, an importDOM for paste, an exportDOM for copy, an importJSON for state hydration, and an exportJSON for state persistence.
A complete MentionNode:
// src/editor/nodes/MentionNode.ts
import {
DecoratorNode,
type DOMConversionMap,
type DOMConversionOutput,
type DOMExportOutput,
type EditorConfig,
type LexicalEditor,
type LexicalNode,
type NodeKey,
type SerializedLexicalNode,
type Spread,
} from 'lexical';
export type SerializedMentionNode = Spread<
{ mentionName: string; userId: string; type: 'mention'; version: 1 },
SerializedLexicalNode
>;
export class MentionNode extends DecoratorNode<JSX.Element> {
__mentionName: string;
__userId: string;
static getType(): string {
return 'mention';
}
static clone(node: MentionNode): MentionNode {
return new MentionNode(node.__mentionName, node.__userId, node.__key);
}
constructor(mentionName: string, userId: string, key?: NodeKey) {
super(key);
this.__mentionName = mentionName;
this.__userId = userId;
}
static importJSON(json: SerializedMentionNode): MentionNode {
return $createMentionNode(json.mentionName, json.userId);
}
exportJSON(): SerializedMentionNode {
return {
mentionName: this.__mentionName,
userId: this.__userId,
type: 'mention',
version: 1,
};
}
static importDOM(): DOMConversionMap | null {
return {
span: (el) => {
if (el.getAttribute('data-lexical-mention') === 'true') {
return { conversion: convertMentionElement, priority: 1 };
}
return null;
},
};
}
exportDOM(): DOMExportOutput {
const el = document.createElement('span');
el.setAttribute('data-lexical-mention', 'true');
el.setAttribute('data-user-id', this.__userId);
el.textContent = `@${this.__mentionName}`;
return { element: el };
}
createDOM(_config: EditorConfig): HTMLElement {
const el = document.createElement('span');
el.className = 'mention';
return el;
}
updateDOM(): boolean {
return false;
}
decorate(_editor: LexicalEditor): JSX.Element {
return <MentionView mentionName={this.__mentionName} userId={this.__userId} />;
}
}
function convertMentionElement(el: HTMLElement): DOMConversionOutput {
const name = el.textContent?.replace(/^@/, '') ?? '';
const userId = el.getAttribute('data-user-id') ?? '';
return { node: $createMentionNode(name, userId) };
}
export function $createMentionNode(name: string, userId: string): MentionNode {
return new MentionNode(name, userId);
}
export function $isMentionNode(node: LexicalNode | null | undefined): node is MentionNode {
return node instanceof MentionNode;
}
The four serialisation methods do four different jobs. importJSON hydrates a node from the editor state JSON, used when loading a saved document. exportJSON serialises a node to JSON, used when saving. importDOM converts a pasted DOM element to a Lexical node, used on paste. exportDOM converts a Lexical node to a DOM element, used on copy. The symmetry rule says importJSON and exportJSON must round-trip, and importDOM and exportDOM must round-trip.
The decorator view is the React component rendered by decorate:
// src/editor/decorators/MentionView.tsx
import { memo } from 'react';
type Props = { mentionName: string; userId: string };
function MentionViewImpl({ mentionName, userId }: Props) {
return (
<span className="mention" data-user-id={userId}>
@{mentionName}
</span>
);
}
export const MentionView = memo(
MentionViewImpl,
(prev, next) => prev.mentionName === next.mentionName && prev.userId === next.userId,
);
Two patterns in this view that the CLAUDE.md rule enforces. The component reads only the props passed by decorate, not editor state. The React.memo comparator compares only the props that change, so the decorator does not re-render on unrelated editor transactions.
Add a custom node scaffolding rule to CLAUDE.md:
## Custom node scaffolding template (CONCRETE)
A new custom node goes in src/editor/nodes/<Name>Node.ts:
class <Name>Node extends ElementNode | TextNode | DecoratorNode {
static getType(): string
static clone(node): <Name>Node
static importJSON(json): <Name>Node
exportJSON(): Serialized<Name>Node
static importDOM(): DOMConversionMap | null
exportDOM(): DOMExportOutput
createDOM(config): HTMLElement
updateDOM(prev): boolean
decorate?(editor): JSX.Element // DecoratorNode only
}
$create<Name>Node(...args): <Name>Node
$is<Name>Node(node): node is <Name>Node
ALWAYS: importJSON / exportJSON symmetry
ALWAYS: importDOM / exportDOM symmetry
NEVER: skip getType, clone, or importJSON
Reading and writing editor state safely
Every interaction with the editor state goes through one of two callbacks. Reads use editor.getEditorState().read(). Writes use editor.update(). The pattern is uniform across plugins, event handlers, and effects.
A read that walks the document and counts mentions:
// src/editor/queries/countMentions.ts
import type { LexicalEditor } from 'lexical';
import { $getRoot, $isElementNode } from 'lexical';
import { $isMentionNode } from '../nodes/MentionNode';
export function countMentions(editor: LexicalEditor): number {
let count = 0;
editor.getEditorState().read(() => {
function walk(node: ReturnType<typeof $getRoot> | null) {
if (!node) return;
if ($isMentionNode(node)) count += 1;
if ($isElementNode(node)) {
for (const child of node.getChildren()) walk(child);
}
}
walk($getRoot());
});
return count;
}
A write that wraps the current selection in a callout:
// src/editor/commands/wrapInCallout.ts
import type { LexicalEditor } from 'lexical';
import { $getSelection, $isRangeSelection, $createParagraphNode } from 'lexical';
import { $createCalloutNode } from '../nodes/CalloutNode';
export function wrapInCallout(editor: LexicalEditor) {
editor.update(() => {
const sel = $getSelection();
if (!$isRangeSelection(sel)) return;
const callout = $createCalloutNode('info');
const paragraph = $createParagraphNode();
callout.append(paragraph);
sel.insertNodes([callout]);
});
}
The $ prefix on $getRoot, $getSelection, $createParagraphNode is a Lexical convention that marks the function as state-dependent. The convention is a flag for the reviewer: a $ function called outside a read() or update() is a bug.
Add a state access rule to CLAUDE.md:
## State access (RECAP)
- Reads: editor.getEditorState().read(() => { $getRoot(), $get..., walk })
- Writes: editor.update(() => { $get..., mutate, $create..., insert })
- $-prefixed functions are state-dependent, NEVER call outside read/update
- NEVER hold the result of $getNodeByKey across the callback boundary
- NEVER pass a $-result to setTimeout, the snapshot is gone next tick
Command listeners with named priorities
Commands are how plugins communicate. A plugin registers a listener for a command, another plugin dispatches it, and the bus routes the dispatch through every listener in priority order until one returns true.
A plugin that intercepts Enter for the mention picker:
// src/editor/plugins/MentionsPlugin.tsx
'use client';
import { useEffect } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH } from 'lexical';
import { useMentionsPickerState } from './useMentionsPickerState';
export function MentionsPlugin() {
const [editor] = useLexicalComposerContext();
const picker = useMentionsPickerState();
useEffect(() => {
const unregister = editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
if (!picker.isOpen) return false;
event?.preventDefault();
picker.confirmSelection();
return true;
},
COMMAND_PRIORITY_HIGH,
);
return unregister;
}, [editor, picker]);
return null;
}
Three things in this plugin that the CLAUDE.md rule enforces. The priority is the named constant COMMAND_PRIORITY_HIGH, not the magic number 3. The handler returns true only when the picker is open, so it does not swallow Enter outside that context. The useEffect cleanup calls unregister, so the listener does not leak on plugin unmount.
For the React effect cleanup patterns plugins depend on, Claude Code with React covers the rules that prevent listener leaks in long-lived editors.
History plugin tuning
The default HistoryPlugin works for most editors. For specific use cases the default coalescing is wrong: a comments box wants every word to be a separate undo step, a long-form editor wants paragraph-sized steps so undo does not feel slow.
// src/editor/plugins/TunedHistoryPlugin.tsx
'use client';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
export function CommentsHistoryPlugin() {
return <HistoryPlugin externalHistoryState={undefined} />;
}
The externalHistoryState prop lets you pass a shared history state if you want undo to span multiple editor instances. The default delay is 1000ms. Set it lower for finer-grained undo, higher for coarser. For comments where every keystroke matters: 300ms. For long-form: 1500ms.
Add a history rule to CLAUDE.md:
## History tuning
- comments: delay 300, separate undo steps by word
- long-form: delay 1500, separate undo steps by paragraph
- collaborative editors: do NOT use HistoryPlugin, use Yjs UndoManager via @lexical/yjs
- ALWAYS ship HistoryPlugin if the editor is user-editable
- NEVER mount two HistoryPlugins in the same composer
Common Claude Code mistakes with Lexical
Six patterns Claude generates incorrectly without CLAUDE.md constraints.
1. State access outside read or update
Claude generates:
function getRootText(editor: LexicalEditor) {
return editor.getEditorState().getRoot().getTextContent();
}
The call throws because $getRoot and friends require an active callback.
Correct pattern:
function getRootText(editor: LexicalEditor): string {
let text = '';
editor.getEditorState().read(() => {
text = $getRoot().getTextContent();
});
return text;
}
2. Custom node without importDOM
Claude generates a MentionNode with exportDOM but no importDOM. The node renders, copies to the clipboard correctly, and disappears on paste because the parser does not know how to read it back.
Correct pattern: every custom node that can be copied must have an importDOM that round-trips with exportDOM.
3. Decorator without React.memo
Claude generates a MentionView that reads from a context or hook and updates on every editor transaction. The component re-renders 100 times per document keystroke.
Correct pattern: React.memo with a comparator that compares only the props that affect rendering.
4. Magic number for command priority
Claude generates editor.registerCommand(KEY_ENTER_COMMAND, handler, 3). The 3 works today and breaks when a plugin registers with a higher number tomorrow.
Correct pattern: COMMAND_PRIORITY_HIGH from the lexical package.
5. Editor created inside the component body
Claude generates:
function MyEditor() {
const editor = createEditor({ namespace: 'app', nodes: [...] });
return <LexicalComposer initialConfig={{ editor }}>{...}</LexicalComposer>;
}
Strict-mode double-mount creates two editors. The user types into one, the other replaces it on the second mount, and the keystrokes vanish.
Correct pattern: use the initialConfig object with namespace, theme, and nodes declared at module scope. Let LexicalComposer create the editor.
6. React state bound to editor content
Claude generates const [content, setContent] = useState('') and <OnChangePlugin onChange={setContent} />, then re-renders the editor with initialEditorState={content} on every render. The editor resets to the saved content on every keystroke.
Correct pattern: pass initialEditorState once, listen to changes via OnChangePlugin, never feed React state back as the editor's initial state.
Add this list to CLAUDE.md with the corrected form for each.
Permission hooks for Lexical projects
A Lexical project has CLI surface for installing packages, generating boilerplate, and running tests. Permission hooks gate the destructive ones.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(pnpm add lexical*)",
"Bash(pnpm add @lexical/*)",
"Bash(pnpm test*)",
"Bash(pnpm vitest*)"
],
"deny": [
"Bash(pnpm remove lexical*)",
"Bash(pnpm remove @lexical/*)",
"Bash(rm -rf src/editor*)"
]
}
}
Adding packages and running tests are safe. Removing core packages or wiping the editor directory needs explicit confirmation.
Building Lexical editors that scale past 100,000 characters
The Lexical CLAUDE.md in this guide produces editors where every state access goes through read() or update(), every custom node has importDOM and exportDOM symmetry plus importJSON and exportJSON symmetry, every decorator is memoised against the attributes that change, every command listener uses a named priority constant with a registered cleanup, the history plugin is tuned for the use case, and the editor is created at module scope or inside useMemo to survive strict-mode double-mount.
The underlying principle is the same as any state-discipline framework with Claude Code. Lexical without a CLAUDE.md produces editors that work in the engineer's hello-world and break on the first real document: state errors because the snapshot was held across boundaries, content loss on copy-paste because the custom node missed importDOM, typing lag because decorators re-render on every transaction, command conflicts because the priorities are magic numbers, double-instantiation because the editor was created inside the component body.
For the React component patterns the editor depends on, Claude Code with React covers the rendering model that pairs cleanly with decorator nodes. For the TypeScript discipline that catches schema errors at compile time, Claude Code with TypeScript covers the type-narrowing patterns that work well with Lexical's $is* guards. Claudify includes a Lexical-specific CLAUDE.md template with the state access rules, the custom node scaffolding pattern, the decorator memoisation contract, the command priority constants, the history tuning matrix, and all six common-mistake rules pre-configured.
Get Claudify. Ship Lexical editors that respect the state model.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify