Nodes

Node Definitions and Node State.

Nodes

A node is the fundamental building block of a DocNode document. Each node has a type, an optional state object, and a position in a tree (parent, siblings, and children).

NodeDefinitions

You define what kinds of nodes your document can contain using NodeDefinitions; at runtime, the document contains nodes that conform to those definitions.

import { Doc, defineNode, string, number, boolean } from "docnode";

// Paragragh with Uppercase is the NodeDefinition (recommended convention)
const Paragraph = defineNode({
  type: "paragraph",
  state: {
    text: string(""),
    rating: number(0),
    visible: boolean(true),
  },
});

const doc = new Doc({ extensions: [{ nodes: [Paragraph] }] });
// paragraph with lowercase is the node (DocNode<typeof Paragraph>)
const paragraph = doc.createNode(Paragraph);
doc.root.append(paragraph);

For a complete description of the properties and methods of the DocNode class, see the API Overview.

RootNode

RootNode is the only NodeDefinition provided automatically by DocNode. Every document has exactly one root node, accessible via doc.root. You cannot insert a new root node. Attempting to do so will throw.

import { Doc, RootNode } from "docnode";

const doc = new Doc();
const root = doc.root;
root.is(RootNode); // true

Node State

Node StateDefinitions are declared inside NodeDefinitions. DocNode ships 3 built‑in StateDefinitions for common primitives: string, number, and boolean.

import { string, number, boolean, defineNode, Doc } from "docnode";

const TestNode = defineNode({
  type: "test",
  state: {
    // Values between parenthesis are the default values
    foo: string(""),
    bar: number(0),
    baz: boolean(false),
  },
});

const doc = new Doc({ extensions: [{ nodes: [TestNode] }] });
const node = doc.createNode(TestNode);
doc.root.append(node);
doc.forceCommit();

// By default, state definitions have 3 methods: get, set and getPrev.

// get: returns the current value
// Note: You should not mutate the value returned from `get`. Use `set` instead.
const foo = node.state.foo.get(); // ""
const bar = node.state.bar.get(); // 0
const baz = node.state.baz.get(); // false

// set: sets the value
node.state.foo.set("hello"); // sets the value directly
node.state.bar.set((current) => current + 1); // you can use an updater function to update the value

// getPrev: returns [changed, previousValue] for the current transaction
const [changedFoo, prevFoo] = node.state.foo.getPrev(); // [true, ""]
const [changedBar, prevBar] = node.state.bar.getPrev(); // [true, 0]
const [changedBaz, prevBaz] = node.state.baz.getPrev(); // [false, false]

In the two subpages of this page (Custom State and Schema Migrations), we explain how you can define your own StateDefinitions and their methods. If string, boolean, and number are sufficient for your use cases and you don't need to migrate the state of a node from one version to another, you can skip that pages.

getPrev: You'll probably want to use getPrev in normalize or change events, as it can only be used with nodes that appear in diff.updated. doc.forceCommit() was added to the previous snippet to illustrate getPrev, since inserted nodes don't appear in diff.updated, but in diff.inserted.

The is method as a type guard

node.is(NodeDefinition) is a type guard. This means that when it returns true, TypeScript automatically infers its type and state.

Using the TestNode from the previous section as an example:

const node = doc.root.first; // Inferred as DocNode<NodeDefinition> | undefined

// TypeScript doesn't know if node is of type TestNode!
const foo = node?.state.foo.get();
//                      ^^^ Property 'foo' does not exist

if (node?.is(TestNode)) {
  // ^? DocNode<typeof TestNode>
  // TypeScript knows here that node is of type TestNode
  node.state.foo.get();
  node.state.bar.get();
  node.state.baz.get();
}

The is method can also accept multiple NodeDefinitions, as we will see in the next section.

Extending nodes

This aspect of DocNode evolves from my experience making the Lexical editor in Payload modular and extensible. It replaces class inheritance with a new model where a Doc can register multiple NodeDefinitions of the same type, making node extension straightforward and composable.

More precisely, DocNode will combine all NodeDefinitions with the same type into a single and internal “resolved” node definition. The only requirement is that NodeDefinitions with the same type must not have duplicate state keys. For DocNode, it is as if there were only one node definition per type at runtime.

import { defineNode, string, Doc, type Extension } from "docnode";

const TextNode = defineNode({
  type: "text",
  state: { text: string("") },
});
const BaseExtension: Extension = { nodes: [TextNode] };

// A third party could publish this extension independently on npm, for example.
const ColoredTextNode = defineNode({
  type: "text",
  state: { color: string("#000000") },
});
const ColoredExtension: Extension = { nodes: [ColoredTextNode] };

const doc = new Doc({
  extensions: [BaseExtension, ColoredExtension],
});

// If you are going to access the `text` and `color` states in a unknown node,
// you will probably want to pass both NodeDefinitions to the `is` method.
if (node?.is(TextNode, ColoredTextNode)) {
  // ^? DocNode<typeof TextNode & typeof ColoredTextNode>
  node.state.text.get();
  node.state.color.get();
}

This merging also applies to type: "root", which allows enriching the state of the root node, useful for adding metadata to the document.