Adding Data to Nodes
Creating an Extension adds an emoji node with its own properties. Sometimes a feature only needs extra data on existing nodes. NodeState lets you attach that data without creating or replacing a node class.
This example adds a reviewed flag to ordinary ParagraphNode instances. It
highlights reviewed paragraphs in the editor and preserves the flag when saving
JSON or exporting and importing HTML. The source blocks are read directly from
named regions in the runnable example.
Defining the state
export const reviewedState = createState('reviewed', {
parse: booleanValue(),
});
Import createState and booleanValue from lexical and define the state once
at module scope. booleanValue() accepts untrusted JSON and
returns false for missing or invalid values. Read with $getState(node, reviewedState) and write with $setState(node, reviewedState, value) inside a
Lexical read or update context, as appropriate.
There is no stateConfigs registration here: this is ad-hoc data attached to an
existing node. Lexical carries it across clones, undo/redo, and JSON saves. A
reviewed paragraph includes "$": {"reviewed": true} in its JSON; the default
false value is omitted. Choose a state key that won't collide with other
features using the same nodes.
Rendering and preserving HTML
NodeState handles JSON, but your feature defines what the data means in HTML.
The following extension maps the flag to data-reviewed="true":
import {
defineImportRule,
DOMImportExtension,
domOverride,
DOMRenderExtension,
sel,
} from '@lexical/html';
import {RichTextExtension} from '@lexical/rich-text';
import {
$getSelection,
$getState,
$isParagraphNode,
$isRangeSelection,
$setState,
booleanValue,
COMMAND_PRIORITY_EDITOR,
configExtension,
createCommand,
createState,
defineExtension,
isHTMLElement,
ParagraphNode,
type StateValueOrUpdater,
} from 'lexical';
export const reviewedState = createState('reviewed', {
parse: booleanValue(),
});
export const SET_REVIEWED_COMMAND = createCommand<
StateValueOrUpdater<typeof reviewedState>
>('SET_REVIEWED_COMMAND');
function $applyReviewedAttribute(node: ParagraphNode, element: HTMLElement) {
if ($getState(node, reviewedState)) {
element.setAttribute('data-reviewed', 'true');
} else {
element.removeAttribute('data-reviewed');
}
}
const ReviewedParagraphRule = defineImportRule({
$import(_context, _element, $next) {
// Let the existing paragraph rule create the nodes and their children.
const nodes = $next();
for (const node of nodes) {
if ($isParagraphNode(node)) {
$setState(node, reviewedState, true);
}
}
return nodes;
},
match: sel.tag('p').attr('data-reviewed', 'true'),
name: '@lexical/examples/reviewed-paragraph',
});
export const ReviewExtension = defineExtension({
dependencies: [
RichTextExtension,
configExtension(DOMImportExtension, {rules: [ReviewedParagraphRule]}),
configExtension(DOMRenderExtension, {
overrides: [
domOverride([ParagraphNode], {
$decorateDOM(node, _prevNode, dom) {
$applyReviewedAttribute(node, dom);
},
$exportDOM(node, $next) {
const output = $next();
if (isHTMLElement(output.element)) {
$applyReviewedAttribute(node, output.element);
}
return output;
},
}),
],
}),
],
name: '@lexical/examples/Review',
register(editor) {
return editor.registerCommand(
SET_REVIEWED_COMMAND,
valueOrUpdater => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const paragraph = selection.anchor.getNode().getTopLevelElement();
if ($isParagraphNode(paragraph)) {
$setState(paragraph, reviewedState, valueOrUpdater);
return true;
}
}
return false;
},
COMMAND_PRIORITY_EDITOR,
);
},
});
DOMRenderExtension's $decorateDOM runs after a paragraph's DOM is created or
updated. It adds or removes the attribute as the flag changes. The separate
$exportDOM hook adds the same attribute to exported HTML, including clipboard
HTML; $decorateDOM only handles the live editor.
The import rule matches <p data-reviewed="true">. Calling $next() keeps the
normal paragraph import behavior, including its children and text formatting,
then $setState restores the extra data. RichTextExtension supplies the
underlying import rules and rich text behavior.
Style the attribute in your application's CSS:
#editor p {
padding: 4px 8px;
border-left: 4px solid transparent;
}
#editor p[data-reviewed='true'] {
background: #e4f5e8;
border-left-color: #277a3c;
}
This CSS belongs to the application. The exported attribute preserves the data; other applications decide how to display it.
Updating the current paragraph
ReviewExtension registers SET_REVIEWED_COMMAND for the paragraph containing
the selection's anchor. Its payload is a boolean or an updater function, matching
$setState. The command handler already runs in an editor update, so a toolbar
or React component only needs to dispatch it:
import {SET_REVIEWED_COMMAND} from './ReviewExtension';
editor.dispatchCommand(SET_REVIEWED_COMMAND, true); // Mark reviewed.
editor.dispatchCommand(SET_REVIEWED_COMMAND, false); // Clear the flag.
editor.dispatchCommand(SET_REVIEWED_COMMAND, reviewed => !reviewed); // Toggle.
The handler returns false when there is no range selection anchored in a
paragraph. $setState marks the paragraph dirty so the render hook runs. Lexical
handles copy-on-write updates.
Putting it together
The application's root extension owns its DOM event handlers and JSON view.
Define it at module scope in AppExtension.ts, with the interface elements in
index.html:
import {ClipboardDOMImportExtension} from '@lexical/clipboard';
import {HistoryExtension} from '@lexical/history';
import {
$generateHtmlFromNodes,
$generateNodesFromDOMViaExtension,
} from '@lexical/html';
import {
$createParagraphNode,
$createTextNode,
$getRoot,
defineExtension,
mergeRegister,
registerEventListener,
registerEventListeners,
} from 'lexical';
import {ReviewExtension, SET_REVIEWED_COMMAND} from './ReviewExtension';
export const AppExtension = defineExtension({
$initialEditorState() {
$getRoot().append(
$createParagraphNode().append(
$createTextNode(
'Review this paragraph without creating a custom node.',
),
),
$createParagraphNode().append(
$createTextNode(
'Export HTML and import it again to keep the review status.',
),
),
);
},
dependencies: [
ReviewExtension,
HistoryExtension,
ClipboardDOMImportExtension,
],
name: '@lexical/examples/node-state-review',
namespace: 'Paragraph Review',
register(editor) {
const html = document.querySelector<HTMLTextAreaElement>('#html')!;
const json = document.querySelector<HTMLTextAreaElement>('#json')!;
const toggle = document.getElementById('toggle-reviewed')!;
return mergeRegister(
editor.registerUpdateListener(({editorState}) => {
json.value = JSON.stringify(editorState.toJSON(true), null, 2);
}),
registerEventListeners(toggle, {
click: () =>
editor.dispatchCommand(SET_REVIEWED_COMMAND, reviewed => !reviewed),
// Keep the editor selection when clicking the toolbar with a pointer.
mousedown: event => event.preventDefault(),
}),
registerEventListener(
document.getElementById('export-html')!,
'click',
() => {
html.value = editor.read('latest', () =>
$generateHtmlFromNodes(editor),
);
},
),
registerEventListener(
document.getElementById('import-html')!,
'click',
() => {
const dom = new DOMParser().parseFromString(html.value, 'text/html');
editor.update(() => {
const nodes = $generateNodesFromDOMViaExtension(dom);
$getRoot()
.clear()
.append(...nodes);
});
},
),
);
},
});
registerEventListener and registerEventListeners return cleanup functions.
mergeRegister combines them with the editor listener's cleanup, so disposing
the editor also removes the toolbar and import/export handlers. No separate
AbortController is needed. For asynchronous work or other APIs that accept an
AbortSignal, register(editor, config, state) can use state.getSignal().
register runs before editor-state initialization. Use afterRegistration when
setup needs the initialized state; these handlers only need it when an event
fires. The clipboard extension routes pasted HTML through DOMImportExtension.
The entry point now builds the editor and attaches its root:
import './styles.css';
import {buildEditorFromExtensions, HMRExtension} from '@lexical/extension';
import {configExtension} from 'lexical';
import {AppExtension} from './AppExtension';
const editor = buildEditorFromExtensions(
AppExtension,
configExtension(HMRExtension, {hot: import.meta.hot ?? null}),
);
editor.setRootElement(document.getElementById('editor'));
// Accept Vite updates; HMRExtension preserves editor state.
// In an application, also call dispose() when removing the editor permanently.
if (import.meta.hot) {
import.meta.hot.accept();
import.meta.hot.dispose(() => editor.dispose());
}
The entry point adds HMRExtension to buildEditorFromExtensions, keeping
Vite-specific setup out of AppExtension. It preserves editor state, selection,
and undo/redo history through Vite updates. The entry point accepts those updates
and disposes the old editor; the extension does not perform those bundler lifecycle steps. Passing
hot: null makes it a no-op when HMR is unavailable.
Use Toggle reviewed, then Export HTML and Import HTML to try the round
trip. Expand Editor state JSON to inspect the ad-hoc data. This view uses
editorState.toJSON(true) to omit default-valued fields. The same
ReviewExtension also works with LexicalExtensionComposer in React.
See DOM rendering and DOM import for more ways to compose render hooks and import rules.