Document Model & Tree Structures
2026-08-08 · 8 min read
Most application state is a list — a feed of posts, a table of rows, a queue of tasks. But some domains model content that is fundamentally hierarchical: a document containing blocks, blocks containing inline runs, runs containing text. When your state is a tree, a flat array won't do.
Rich text editors, collaborative documents, spreadsheets, and drawing tools all share this problem. Understanding how they solve it explains a category of architecture decisions that don't appear in simpler apps.
The rest of this post uses a rich text editor as the concrete example — it's the clearest case and the one most commonly encountered in frontend system design interviews. The concepts (tree model, node types, immutable state, selection) apply equally to other tree-shaped domains, just with different node types. A drawing tool has FrameNode, ShapeNode, TextNode; a spreadsheet has SheetNode, RowNode, CellNode.
When state is a tree
A rich text document isn't a string. "Hello world" is two text runs with different formatting, inside a paragraph, inside a document.
A run is a continuous sequence of characters that share the exact same formatting properties. Any change to a property — bold, color, font, language — ends the current run and starts a new one. The structure matters — you can't represent it without hierarchy.
RootNode
├── ParagraphNode
│ ├── TextNode("Hello ", bold=false)
│ └── TextNode("world", bold=true)
├── HeadingNode(level=2)
│ └── TextNode("Subtitle")
└── BlockquoteNode
└── ParagraphNode
└── TextNode("A quote")
This mirrors the hierarchical nature of the content: document → blocks → inline runs → text. Operations like "make the selected text bold" or "split this paragraph at the cursor" are tree mutations, not string operations.
Node types
The editor defines its own node types as an internal data model — independent of the browser's DOM. Despite the similar naming, an editor TextNode and a DOM TextNode are different things. The editor's nodes are the source of truth; the DOM is what gets rendered from them.
Editors distinguish between nodes that can contain children and nodes that contain only content.
Element nodes (block nodes)
Containers. They hold other nodes as children.
ParagraphNode— a block of textHeadingNode(level)— h1–h6BlockquoteNode— a quoted blockListNode/ListItemNode— ordered and unordered listsTableNode/TableRowNode/TableCellNode— tabular content
Element nodes define structure. They have no text content of their own — only their children do.
Text nodes (leaf nodes)
The actual content. A text node holds a string and a set of formatting flags:
{
type: 'text',
text: 'world',
bold: true,
italic: false,
underline: false,
code: false,
}
Text nodes are always leaves — they cannot have children. Formatting is stored on the node itself, not as wrapper elements like HTML's <strong> or <em>.
Decorator nodes
For content that isn't text — images, videos, mentions, embeds. A decorator node renders an arbitrary React component at its position in the tree. It's a leaf node (no children) but its visual output isn't text.
{
type: 'mention',
userId: '42',
displayName: '@paul',
}
// Renders as: <MentionComponent userId="42" />
EditorState
Modern editors like Lexical, Slate, and ProseMirror don't mutate the tree in place. They maintain an immutable editor state — a snapshot of the entire document tree plus the current selection.
EditorState = { root: RootNode, selection: Selection }
On every change:
- Create a new
EditorStatefrom the current one - Apply the mutation to produce the new tree
- Diff the old and new states
- Apply the minimal set of DOM mutations to reconcile the rendered output
// Lexical's update model
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.formatText('bold'); // mutates within the update, produces new state
}
});
The update callback runs against a mutable draft of the state. When it completes, Lexical compares the new state to the previous one and patches the DOM — exactly like React's virtual DOM reconciliation, but for document content.
At any point two states exist:
- Current — what's rendered in the DOM right now
- Pending — the new state being computed in an
updatecall
Immutability makes undo/redo trivial: the undo stack is just a list of previous EditorState snapshots. Rolling back is swapping the current state for an earlier one and reconciling the DOM.
Selection
Selection in a document editor is more complex than a DOM range. It needs to survive tree mutations — if you insert text before the selection, the selection offsets need to update.
Selection = {
anchor: { key: 'node-id-7', offset: 3, type: 'text' },
focus: { key: 'node-id-7', offset: 8, type: 'text' },
}
anchor— where the selection starts (node key + character offset within that node)focus— where the selection ends- Collapsed selection —
anchor === focus, this is just a cursor position with no highlighted range - Backwards selection — user dragged right-to-left, so
focuscomes beforeanchorin document order
Selection is stored by node key and character offset, not by DOM position. When the tree mutates, the editor updates selection positions to remain valid. If the anchor node is deleted, the selection collapses to the nearest valid position.
"Hello world"
H e l l o w o r l d
0 1 2 3 4 5 6 7 8 9 10
anchor = { offset: 0 } → cursor before "H"
focus = { offset: 5 } → cursor before " " (after "Hello")
Selected text: "Hello"
The contentEditable trap
The naive approach to a rich text editor is to use contenteditable — a browser attribute that makes any element directly editable. The browser handles user input, the DOM is the state.
This seems convenient but creates an insurmountable problem: the browser makes uncontrolled DOM mutations inside a contenteditable element. Different browsers handle the same input differently. Paste behavior varies. IME composition (see Internationalization) mutates the DOM in ways that are hard to intercept. Undo history is the browser's, not yours.
Modern editors invert this model entirely:
contenteditable (naive):
User input → browser mutates DOM → DOM is source of truth
Modern editor (Lexical, Slate, ProseMirror):
User input → editor intercepts → updates internal model → reconciles DOM
↑ source of truth
The editor still uses a contenteditable container — it needs to to receive keyboard events and cursor positioning from the browser. But it treats the contenteditable DOM as a render target, not a source of truth. On every browser input event (beforeinput, keydown, composition events), the editor:
- Intercepts the event and calls
event.preventDefault() - Interprets the intended operation (insert character, delete word, format selection)
- Applies the operation to its internal tree model
- Reconciles the DOM to match the new model
The DOM is always downstream of the model. If the DOM ever diverges (which browsers sometimes cause), the editor detects it and re-renders from the model.
Tree operations
Common editing operations map to tree mutations:
Insert text — find the text node at the cursor, insert the character at the offset. If the cursor is at a node boundary, insert a new text node.
Split paragraph — pressing Enter inside a ParagraphNode splits it into two sibling ParagraphNodes at the cursor position. The text before the cursor stays in the first; the text after moves to the second.
Toggle bold — for a range selection spanning multiple text nodes:
- Split text nodes at the selection boundaries so the selection aligns with node boundaries
- Toggle the
boldflag on every text node within the selection - Merge adjacent text nodes with identical formatting to keep the tree normalized
Delete — remove the character at the cursor offset. If it's the last character in a text node, remove the text node. If it's the last text node in a block, merge the block with the previous sibling.
Each of these is a pure function: given the current EditorState and the operation, produce a new EditorState. No DOM reads. No side effects inside the transform.
Why not just use the DOM?
The DOM is a tree, so it seems like a natural fit. The problem is that the DOM is a rendering artifact, not a data model. It mixes content with layout, handles browser-specific quirks, and can't be serialized or transmitted cleanly.
A document model tree is independent of rendering. The same tree can be:
- Rendered to DOM (the editor)
- Serialized to JSON (persistence, network transport)
- Converted to Markdown or HTML (export)
- Diffed for operational transforms (collaborative editing — see Real-Time & Collaborative Editing)
- Replayed for undo/redo
Owning the model separately from the DOM is what makes all of these possible.