Plugin Architecture
2026-08-09 · 6 min read
A rich text editor could ship bold, italic, links, mentions, images, tables, undo/redo, and collaboration all baked into a single class. Every feature would be coupled to every other. Adding a new one means touching the core. Removing one means auditing what broke.
Plugin architecture inverts this. The core is a minimal host: it owns the document model, processes updates, and manages the selection. Every feature — including bold — is a plugin that registers with the core through a defined API. The core doesn't know about bold. It just knows that some code registered interest in FORMAT_TEXT_COMMAND, and when that command fires, it calls that code.
This is the model Lexical, ProseMirror, and CodeMirror all converge on. It also appears in design tools (Figma plugins), IDEs (VS Code extensions), and build systems (webpack/Vite plugins). The domain changes; the shape doesn't.
The four integration points
Plugins connect to the core through four mechanisms. Each has a specific job.
Commands
Commands are named, dispatchable actions. The core defines the shape; plugins respond to them.
// Any part of the app can dispatch
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
// The bold plugin registers a handler
editor.registerCommand(
FORMAT_TEXT_COMMAND,
(format: TextFormatType) => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.formatText(format);
}
});
return true; // consumed — stop propagation
},
COMMAND_PRIORITY_LOW
);
Returning true marks the command as consumed and stops it from propagating to lower-priority handlers. This lets plugins intercept and override each other in a controlled way — a read-only plugin can register at COMMAND_PRIORITY_CRITICAL and return true for all mutation commands, blocking everything below it without touching core logic.
Priority levels (CRITICAL → EDITOR → HIGH → LOW → NORMAL) give a predictable ordering when multiple handlers respond to the same command. Higher priority runs first.
Listeners
Listeners react to state changes — they observe without modifying. A toolbar plugin uses a selection listener to know when to enable or disable the bold button.
// Re-render toolbar when selection changes
const unregister = editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const selection = $getSelection();
setIsBold($isRangeSelection(selection) && selection.hasFormat('bold'));
});
});
// Clean up when the plugin unmounts
return unregister;
Listeners are read-only by contract. They run after the update has been committed, so the state they see is stable.
Transforms
Transforms run during the update cycle — they can modify the document in response to a change. This is how auto-formatting works: the user types https://... and the auto-link plugin converts it to a LinkNode before the update is committed.
editor.registerNodeTransform(TextNode, (node) => {
const text = node.getTextContent();
const match = URL_REGEX.exec(text);
if (!match) return;
// Replace the TextNode with a LinkNode wrapping the URL
const linkNode = $createLinkNode(match[0]);
linkNode.append($createTextNode(match[0]));
node.replace(linkNode);
});
Transforms run in a loop until no further changes occur — if a transform produces a node that triggers another transform, both run. This makes them composable but requires care: a badly written transform that always modifies its node will loop infinitely.
Node types
New content types register new node classes. The editor's core only knows about primitive nodes (TextNode, ParagraphNode). Everything else — mentions, images, code blocks — is registered by a plugin.
class MentionNode extends DecoratorNode<JSX.Element> {
__username: string;
static getType(): string {
return 'mention';
}
static clone(node: MentionNode): MentionNode {
return new MentionNode(node.__username, node.__key);
}
createDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'mention';
return span;
}
decorate(): JSX.Element {
return <MentionComponent username={this.__username} />;
}
}
// Register on editor creation
const editor = createEditor({
nodes: [MentionNode, ImageNode, CodeBlockNode],
});
DecoratorNode lets a plugin render arbitrary React (or framework-agnostic) UI inside the editor's DOM without the core knowing anything about it. The editor just sees a node; the plugin controls what renders inside it.
Plugin lifecycle
A plugin is typically a function that receives the editor instance, sets up its registrations, and returns a cleanup function.
function registerAutoLinkPlugin(editor: LexicalEditor): () => void {
const removeTransform = editor.registerNodeTransform(TextNode, autoLinkTransform);
const removeCommand = editor.registerCommand(
TOGGLE_LINK_COMMAND,
handleToggleLink,
COMMAND_PRIORITY_LOW
);
return () => {
removeTransform();
removeCommand();
};
}
Every register* call returns an unregister function. Calling it removes the handler from the core's registry — no reference leaks, no double-firing after a component unmounts. This is the same pattern as addEventListener / removeEventListener, applied at the plugin level.
In React, plugins mount as components with no rendered output — they use useEffect to register on mount and clean up on unmount:
function AutoLinkPlugin(): null {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return registerAutoLinkPlugin(editor);
}, [editor]);
return null;
}
The editor's plugin tree ends up looking like a list of null-rendering components. Features are added and removed by including or excluding them from the tree — no changes to core, no changes to other plugins.
<LexicalComposer initialConfig={config}>
<RichTextPlugin />
<BoldPlugin />
<LinkPlugin />
<AutoLinkPlugin />
<MentionPlugin />
<HistoryPlugin /> {/* undo/redo */}
<CollaborationPlugin />
</LexicalComposer>
How plugins compose
Plugins don't call each other directly. They communicate through the command system. The mention plugin doesn't import the link plugin — it dispatches commands and registers node types independently.
This decoupling is what makes the architecture scale. A product that only needs bold and links ships two plugins and nothing else. A product that adds image upload adds one plugin and changes nothing. The core doesn't grow; the plugin list does.
Composition also means conflict is contained. If two plugins register handlers for the same command, priority determines the winner. The higher-priority handler runs first and can return true to prevent others from running. This is explicit — you can see why a command was consumed — rather than hidden inside a monolithic handler.
Compared to a monolith
In a monolithic editor, bold support looks like this: the editor class has an isBold property, a toggleBold method, a keyboard shortcut handler, a toolbar button handler, and serialization logic for bold text — all interleaved with italic, link, and every other feature.
In a plugin architecture, bold is fully self-contained:
bold-plugin/
index.ts — registers FORMAT_TEXT_COMMAND handler
BoldButton.tsx — toolbar button, uses selection listener
shortcuts.ts — keyboard shortcut → dispatches FORMAT_TEXT_COMMAND
Bold can be tested in isolation, shipped independently, and removed without touching any other file.
Where this appears
Rich text editor — the canonical case. The document model is the core; bold, italic, links, mentions, and collaboration are all plugins. Adding a feature means registering a plugin, not modifying the core.
Google Docs / Notion — same structure at a larger scale. The collaboration plugin sits alongside formatting plugins. It registers transforms that apply remote operations and commands that dispatch local ones. The core doesn't know about collaboration; it just processes updates.
Email client (compose view) — the composer is a rich text editor with a constrained plugin set: bold, italic, link, inline image. The same editor core can power both the full-featured compose view and a minimal inline reply box with fewer plugins registered.