packages/lexical-website/docs/concepts/node-state.md
The NodeState API introduced in v0.26.0 allows arbitrary state to be added ad-hoc to any node in a way that participates with reconciliation, history, and JSON serialization.
NodeState allows your application to define keys that can be stored on any node with automatic JSON support, you can even add state to the root node to store document-level metadata.
:::tip
You can even add node state to the RootNode to store document-level metadata, which wasn't possible at all before!
:::
With a combination of NodeState and other APIs such as Listeners or Transforms you can likely shape the editor to meet your needs without having to do much Node Customization.
Even when you are subclassing nodes, using NodeState instead of additional properties to store the node's data can be more efficient and will save you from writing a lot of boilerplate in the constructor, updateFromJSON, and exportJSON.
createState creates a StateConfig which defines the key and configuration for your NodeState value.
The key must be locally unique, two distinct StateConfig must not have the same string key if they are to be used on the same node.
Typical usage will look something like this:
const questionState = createState('question', {
parse: (v) => (typeof v === 'string' ? v : ''),
});
The required parse function serves two purposes:
undefined (or any invalid value) it should return some
default value (which may be undefined or null or any other value you
choose)In this case, the question must be a string, and the default is an empty string.
See the createState API documentation for more details, there are other optional settings that you may want to define particularly if the value is not a primitive value such as boolean, string, number, null, or undefined.
:::tip
We recommend building a library of small reusable parse functions for the data types that you use, or a library that can be used to generate them such as zod, ArkType, Effect, Valibot, etc. especially when working with non-primitive data types.
:::
$getState gets the NodeState value from the given node, or the default if that key was never set on the node.
const question = $getState(pollNode, questionState);
See also $getStateChange if you need an efficient way to determine if the state has changed on two versions of the same node (typically used in updateDOM, but may be useful in an update listener or mutation listener).
$setState sets the NodeState value on the given node.
const question = $setState(
pollNode,
questionState,
'Are you planning to use NodeState?',
);
:::tip
The last argument is a ValueOrUpdater, just like with React's useState setters. If you use an updater function and the value does not change, the node and its NodeState won't be marked dirty.
:::
The NodeState for a node, if any values are set to non-default values, is
serialized to a record under a single
NODE_STATE_KEY
which is equal to '$'.
{
"type": "poll",
"$": {
"question": "Are you planning to use NodeState?"
}
}
:::tip
By default, it is assumed that your parsed values are JSON serializable, but for advanced use cases you may use values such as Date, Map, or Set that need to be transformed before JSON serialization. See the StateValueConfig API documentation.
:::
$configNodes that declare a StateConfig in $config
with flat: true lift that key to the top level of the serialized node
instead of nesting it under '$'. This makes the JSON shape identical
to a legacy node that stored the value as a __property instance
variable, so existing payloads continue to round-trip after the node is
migrated to NodeState.
For example, a ColoredNode that extends TextNode and declares a flat
color state (see Extending TextNode with $config):
$config() {
return this.config('colored', {
extends: TextNode,
stateConfigs: [{flat: true, stateConfig: colorState}],
});
}
serializes as:
{
"type": "colored",
"text": "hello",
"color": "red"
}
A non-flat (default) state on the same node would instead appear under
'$':
{
"type": "colored",
"text": "hello",
"$": {
"color": "red"
}
}
In both cases, a key is only emitted when the current value is not equal
to the default returned by its parse function — see
Efficiency.
:::note
Don't reuse a flat key that a superclass already serializes (e.g. text
on TextNode).
:::
A node that stores data as a __property instance variable with custom
exportJSON/importJSON/updateFromJSON can be migrated to NodeState
with flat: true without changing its serialized JSON shape.
Before — ColoredNode with a __color property and hand-written
serialization (see the full legacy example in
Extending TextNode with $config):
export type SerializedColoredNode = Spread<
{color?: string},
SerializedTextNode
>;
export class ColoredNode extends TextNode {
__color: string;
constructor(text: string = '', color: string = DEFAULT_COLOR, key?: NodeKey) {
super(text, key);
this.__color = color;
}
static getType(): string {
return 'colored';
}
static clone(node: ColoredNode): ColoredNode {
return new ColoredNode(node.__text, node.__color, node.__key);
}
static importJSON(serializedNode: SerializedColoredNode) {
return new ColoredNode().updateFromJSON(serializedNode);
}
updateFromJSON(serializedNode: SerializedColoredNode) {
const self = super.updateFromJSON(serializedNode);
self.__color =
typeof serializedNode.color === 'string'
? serializedNode.color
: DEFAULT_COLOR;
return self;
}
exportJSON(): SerializedColoredNode {
return {
...super.exportJSON(),
color: this.__color === DEFAULT_COLOR ? undefined : this.__color,
};
}
}
After — same on-the-wire JSON, no hand-written serialization:
const colorState = createState('color', {
parse: (v) => (typeof v === 'string' ? v : DEFAULT_COLOR),
});
export class ColoredNode extends TextNode {
$config() {
return this.config('colored', {
extends: TextNode,
stateConfigs: [{flat: true, stateConfig: colorState}],
});
}
}
No exportJSON, importJSON, updateFromJSON, clone, or
afterCloneFrom override is needed: $config installs clone and
importJSON, and the base exportJSON/updateFromJSON/afterCloneFrom
on LexicalNode/TextNode already round-trip NodeState. Read the color
via $getState(node, colorState) and write it via
$setState(node, colorState, value) instead of node.__color.
:::tip
For a seamless migration, keep the JSON key name the same — for a flat
state, that's the first argument to createState. Migrating from a
non-flat NodeState to a flat NodeState also works: state that appears
under the $ (NODE_STATE_KEY) is still parsed even when the state is
configured as flat, and the flat value takes precedence when both are
present.
:::
NodeState uses a copy-on-write scheme to manage each node's state. If none of the state has changed, then the NodeState instance will be shared across multiple versions of that node.
:::info
In a given reconciliation cycle, the first time a Lexical node is marked dirty
via getWritable will create a new instance of that node. All properties
of the previous version are set on the new instance. NodeState is stored
as a single property, and no copying of the internal state is done
until the NodeState itself is marked writable.
:::
When serializing to JSON, each key will only be stored if the value is not equal to the default value. This can save quite a lot of space and bandwidth.
Parsing and serialization is only done at network boundaries, when integrating with JSON or Yjs. When a value changes from an external source, it is only parsed once the first time it is read. Values that do not come from external sources are not parsed, and values that are not used are never parsed.
Current:
flat) with $config
(see #7260).Future:
This example demonstrates an advanced use case of storing a style object on TextNode using NodeState.
<iframe width="100%" height="600" src="https://stackblitz.com/github/facebook/lexical/tree/main/examples/node-state-style?embed=1&file=src%2FApp.tsx&terminalHeight=0&ctl=1&showSidebar=0&devtoolsheight=0&view=preview" sandbox="allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-presentation allow-same-origin allow-scripts" title="Node State Style Example"></iframe>