DOMRenderExtension
DOMRenderExtension and everything described on this page are marked
@experimental and may change between any two Lexical releases —
including breaking renames, signature changes, or behavior changes —
until the API stabilizes. We track issues and proposals in the
GitHub repo; breaking changes
will be called out in release notes. Apps that depend on this
extension should pin their Lexical version and treat upgrades as
intentional.
The legacy on-class createDOM / updateDOM / exportDOM and the
default $generateHtmlFromNodes entry are unchanged and remain the
supported default for production apps that don't want to track an
experimental API.
DOMRenderExtension lets you override how Lexical nodes are rendered
to the DOM during reconciliation (the createDOM / updateDOM /
decorateDOM cycle) and how they're serialized to HTML for clipboard
export and $generateHtmlFromNodes. Both the in-editor render path
and the export path consult the same set of overrides, so a single
declaration can shape both.
For the inverse direction — converting a DOM tree into Lexical nodes — see DOMImportExtension.
When to use it
You want DOMRenderExtension instead of subclassing or
registerMutationListener whenever the change is how a node
becomes DOM:
- Tagging every rendered element with a state-driven attribute (e.g.
data-id,data-color). - Adding an extra wrapping element around a specific node type's children without subclassing.
- Stripping or rewriting attributes during HTML export (e.g. removing
the
white-space: pre-wrapstyle when a TextNode doesn't need it). - Customizing the root element returned by
$generateDOMFromRoot. - Branching export behavior on whether this is a clipboard copy vs. a full-document serialization.
The render and export overrides are middleware-shaped — each one
calls $next() to get the default (or lower-priority) result and
returns its own. This composes cleanly across extensions: each
extension declares its overrides without coordinating with the
others.
Quick start
import {
buildEditorFromExtensions,
configExtension,
} from '@lexical/extension';
import {DOMRenderExtension, domOverride} from '@lexical/html';
import {defineExtension, isHTMLElement, TextNode} from 'lexical';
const editor = buildEditorFromExtensions(
defineExtension({
name: 'app',
dependencies: [
configExtension(DOMRenderExtension, {
overrides: [
domOverride([TextNode], {
$createDOM(node, $next, editor) {
const dom = $next();
dom.setAttribute('data-fluid', 'true');
return dom;
},
}),
],
}),
],
}),
);
The override above tags every rendered TextNode with
data-fluid="true". It composes with whatever else
DOMRenderExtension is configured to do for TextNode — both inside
the editor and during HTML export.
The same override fires for the editor's in-place reconciliation
(createDOM/updateDOM/decorateDOM) AND for HTML export
($exportDOM). When the override is render-only or export-only,
that's just a matter of which methods you implement.
Overrides
An override is a DOMRenderMatch<T> built with domOverride. It
targets a set of node classes (or '*') and supplies any subset of
the following middleware methods:
| Override | When it's called | Replaces / wraps |
|---|---|---|
$createDOM | Reconciler creates the DOM for a node | node.createDOM |
$updateDOM | Reconciler updates an existing DOM node | node.updateDOM |
$decorateDOM | After create or update, after children reconcile | (additive — no default to replace) |
$getDOMSlot | Reconciler asks "where do children attach?" for an ElementNode | ElementNode.getDOMSlot |
$exportDOM | Building HTML for clipboard or $generateHtmlFromNodes | node.exportDOM |
$shouldExclude | Decide whether to omit a node from HTML | ElementNode.excludeFromCopy |
$shouldInclude | Decide whether to include a node in HTML, typically based on selection | The default selection ? node.isSelected(selection) : true |
$extractWithChild | Include a node because one of its children is selected, even if it wouldn't otherwise be included | node.extractWithChild |
All except $decorateDOM are $next()-style middleware. Calling
$next() returns the default (or lower-priority override) value; you
can use it as-is, transform it, or replace it.
$decorateDOM is the exception: it has no $next argument. All
applicable $decorateDOM functions are called unconditionally, and
the ordering is equivalent to an implicit $next call FIRST — lower-
priority handlers run before higher-priority ones. Use it for in-place
DOM tweaks (setting attributes, applying state-driven styles) where
you always want to layer on top of what others have already done.
Matching nodes
domOverride takes either '*' (matches every node) or an array of
NodeMatch<T> entries — each entry is either a node Klass (e.g.
TextNode, ParagraphNode) or a $isNodeGuard predicate.
// Apply to all nodes
domOverride('*', { $decorateDOM(node, _, dom) { /* … */ } });
// Apply to TextNode and its subclasses (the Klass form covers subclasses)
domOverride([TextNode], { $createDOM(node, $next) { /* … */ } });
// Apply to a custom guard
domOverride([$isQuoteNode], { $exportDOM(node, $next) { /* … */ } });
Using the Klass form is significantly cheaper than a guard
function — the dispatcher can compile class-based matches into a
direct lookup keyed by node type. Reserve $isNodeGuard for cases
where the same DOM behavior should apply to a structurally identified
set of nodes that don't share a common ancestor class.
Priority
The relative priority of two overrides is determined by:
- Wildcards (
'*') have highest priority — they wrap everything. - Predicates (
$isParagraphNode) come next. - Subclasses before parents — an override for
ParagraphNoderuns before one forElementNode. - Extensions closer to the root run earlier — apps wrap library overrides.
- Extensions depended on later run earlier — when two extensions are at the same depth, the one merged later wins.
- Overrides defined later in the same array — the last entry of
a
configExtension(DOMRenderExtension, {overrides: […]})array runs first.
$next() walks DOWN this priority chain — your override runs first,
then the next-lower override (or eventually the node's default
implementation).
Writing read-only
The overrides are called during reconciliation and export, both of
which are READ-ONLY contexts. Don't call editor.update() or mutate
node state from inside an override — Lexical is in the middle of
producing the DOM for the state it already has. Use a node transform
or update listener if you need to react to changes.
Worked examples
State-driven attribute on every node
A common pattern: every node carries some app-defined state (e.g. a
unique id from NodeState) and you want it surfaced as a DOM
attribute in both the editor and the HTML export.
import {createState, $getState, $setState, $getStateChange} from 'lexical';
import {DOMRenderExtension, domOverride} from '@lexical/html';
const idState = createState('id', {
parse: (v) => (typeof v === 'string' ? v : null),
});
configExtension(DOMRenderExtension, {
overrides: [
domOverride('*', {
$createDOM(node, $next) {
const dom = $next();
const id = $getState(node, idState);
if (id) {
dom.setAttribute('id', id);
}
return dom;
},
$updateDOM(nextNode, prevNode, dom, $next) {
if ($next()) {
// Lower-priority override requested re-mount; nothing more for us to do
return true;
}
const change = $getStateChange(nextNode, prevNode, idState);
if (change) {
const [id] = change;
if (id) {
dom.setAttribute('id', id);
} else {
dom.removeAttribute('id');
}
}
return false;
},
}),
],
});
Note the $updateDOM shape: it returns true to tell the reconciler
to unmount and re-create the DOM (e.g. when the element tag would
change), or false after performing an in-place update. Calling
$next() lets a lower-priority handler signal the re-mount.
Customizing the slot for an ElementNode
$getDOMSlot controls where child nodes attach in the DOM. The
result of $next() is the default ElementDOMSlot returned by
ElementNode.getDOMSlot; you can re-derive a new one to insert an
extra wrapping element while the root createDOM returns one
HTMLElement:
domOverride([SectionNode], {
$createDOM(node, $next) {
const root = $next();
const wrapper = document.createElement('div');
wrapper.className = 'section-inner';
root.appendChild(wrapper);
return root;
},
$getDOMSlot(node, dom, $next) {
// Children go into .section-inner, not directly in the root <section>
const inner = dom.querySelector('.section-inner');
return $next().withElement(inner as HTMLElement);
},
});
Adjusting HTML export
$exportDOM returns a DOMExportOutput ({element, after?, append?, $getChildNodes?}).
Override it to strip unwanted attributes or rewrite the output for
clipboard / $generateHtmlFromNodes:
domOverride([TextNode], {
$exportDOM(_node, $next) {
const result = $next();
if (isHTMLElement(result.element)) {
// Drop white-space: pre-wrap when not needed
const textContent = result.element.textContent || '';
if (
result.element.style.whiteSpace === 'pre-wrap' &&
!/^\s|\s$|\s\s/.test(textContent)
) {
result.element.style.removeProperty('white-space');
if (result.element.getAttribute('style')?.trim() === '') {
result.element.removeAttribute('style');
}
}
}
return result;
},
});
Selection-aware export filters
$shouldExclude, $shouldInclude, and $extractWithChild together
control which nodes appear in the HTML output, particularly when a
selection is in play. They run in this precedence order (highest to
lowest):
$shouldExcludereturnstrue⇒ the node is omitted (and if it's an ElementNode, its children may still be hoisted in its place).$shouldIncludereturnstrue⇒ include the node.$extractWithChildreturnstruefor any of its children ⇒ include the node so the included child has its proper wrapper (e.g. aListNodewhen one of itsListItemNodes is selected).
domOverride([CommentMarkNode], {
// Never include comment marks in exported HTML, but keep their children.
$shouldExclude: () => true,
});
Render context
Some overrides need to know "is this an export or an editor render?"
or "is this the root call from $generateDOMFromRoot?". Use the
render context for that.
createRenderState
Mint a typed context key with createRenderState:
import {createRenderState} from '@lexical/html';
// True if this serialization is heading to the clipboard (vs. an
// editor reconciliation).
const ClipboardCopyState = createRenderState('clipboardCopy', Boolean);
Read it inside an override via $getRenderContextValue:
import {$getRenderContextValue} from '@lexical/html';
domOverride([TableNode], {
$exportDOM(node, $next, editor) {
const result = $next();
if ($getRenderContextValue(ClipboardCopyState, editor)) {
// Strip editor-only data-* attributes for cleaner clipboard HTML
// …
}
return result;
},
});
Layer values for an entire render call via
DOMRenderConfig.contextDefaults:
configExtension(DOMRenderExtension, {
contextDefaults: [
contextValue(ClipboardCopyState, false),
],
overrides: [/* … */],
})
Or for a single call via $withRenderContext:
import {$withRenderContext, contextValue} from '@lexical/html';
const html = $withRenderContext(
[contextValue(ClipboardCopyState, true)],
editor,
)(() => $generateHtmlFromNodes(editor, selection));
Built-in render states
@lexical/html ships two render states out of the box:
RenderContextExport—truewhile serializing to HTML ($generateDOMFromNodes,$generateDOMFromRoot,$generateHtmlFromNodes). Use this to branch behavior between the in-editor render and an HTML export.RenderContextRoot—trueonly during the outermost$generateDOMFromRootcall (i.e. when the root node itself is being serialized as a<div role="textbox">wrapper). Useful when the root node should appear differently in a full-document export than as a child of some other element.
Entry points
Three top-level helpers consume the configured overrides:
| Function | Purpose |
|---|---|
$generateDOMFromNodes(container, selection?, editor?) | Walks RootNode.getChildren() and appends each to container. Sets RenderContextExport=true. |
$generateDOMFromRoot(container, root?) | Like the above but includes the root node itself (wrapped in a <div role="textbox"> by default). Sets RenderContextExport=true and RenderContextRoot=true. |
$generateHtmlFromNodes(editor, selection?) | Convenience: creates a <div>, calls $generateDOMFromNodes, returns its innerHTML. |
All three are read-only (use them inside editor.read() or alongside
your own editor.update()).
Capabilities
Current:
- Override
createDOM,updateDOM,decorateDOM,getDOMSlot,exportDOM,shouldExclude,shouldInclude,extractWithChildper node class or globally. - Middleware
$next()chain composes across extensions. - Typed render context (
createRenderState,RenderContextExport,RenderContextRoot) lets overrides branch on the calling mode. - A single declaration applies to both in-editor reconciliation and HTML export.
Future:
- The legacy
node.createDOM/node.updateDOM/node.exportDOMon each node class continues to work side-by-side; nothing in this iteration flips the default. Extensions opt-in to the override pipeline, and the resulting overrides supersede the on-class defaults for matching nodes.