Skip to main content

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​

examples/node-state-review/src/ReviewExtension.ts
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":

examples/node-state-review/src/ReviewExtension.ts
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:

examples/node-state-review/src/styles.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:

examples/node-state-review/src/AppExtension.ts
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:

examples/node-state-review/src/main.ts
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.