Search IOCombats

Search challenges, guides, questions and articles

System DesignTopic 6 of 15AdvancedAug 3, 2026

Rich Text Editor

Design a formatting editor on contentEditable, with a structured document model as the source of truth, commands as transactions, undo via a transaction log, and paste sanitisation.

frontend-system-designpractice-problemeditor

The Problem

Design an editor where a user types formatted text - bold, italic, headings, lists, links - with a toolbar, keyboard shortcuts, undo and redo, and paste from external sources.

Every browser ships an editing surface: set contentEditable on a div and the user can type into it. That is where the difficulty starts rather than ends. contentEditable gives you a mutable region whose contents the browser controls, and browsers disagree about almost everything - what Enter produces, what bold emits, what a paste inserts. A production editor inverts the relationship: a structured document model is the source of truth, and the contentEditable DOM is a render target. Getting that inversion right, and keeping the caret correct through it, is the whole problem.

Requirements

Functional

  • Inline formatting (bold, italic, code, link) and block types (paragraph, heading, list item, quote).
  • Toolbar reflecting the formats active at the caret, plus keyboard shortcuts.
  • Undo and redo grouped the way a human expects.
  • Paste from external sources, preserving supported formatting and discarding everything else.
  • Serialise to a storable format and reload without loss.

Non-functional

  • Typing latency imperceptible - no full re-render per keystroke.
  • Pasted content can never execute script or inject unsupported markup.
  • Document format is versioned and machine-transformable.
  • Editor is announced and operable with a screen reader.

Why contentEditable Cannot Be the Model

Set contentEditable and the browser hands you an editing surface plus a set of decisions you did not make:

  • Enter produces different markup per engine - a <div>, a <p>, or a <br> depending on browser and context.
  • document.execCommand('bold') emits <b> in one engine and <span style="font-weight: bold"> in another. It is deprecated, was never specified precisely, and is unfixable.
  • Paste injects arbitrary foreign markup - Word and Google Docs produce deeply nested spans with inline styles, conditional comments and proprietary class names.
  • Invisible mutations. Autocorrect, spellcheck, IME composition and browser extensions change the tree, sometimes without the events you were listening for.

If the DOM is your model, then your document format is "whatever those systems produced", and you cannot reliably serialise, validate, diff or transform it. Two documents that look identical can have completely different markup.

The inversion is the entire architectural move: model is truth, DOM is output. The browser can do what it likes to the DOM, because the next render overwrites it from the model.

Diagram
100%
flowchart TB subgraph MODEL["Document model - the only source of truth"] M["doc<br/>├ heading level 1 - 'Title'<br/>├ paragraph<br/>│ ├ text 'Hello '<br/>│ └ text 'world' marks: [bold]<br/>└ bulletList<br/> ├ listItem > paragraph > text 'one'<br/> └ listItem > paragraph > text 'two'"] end subgraph VIEW["View layer"] V["reconciler - diffs previous model<br/>against next, patches only what changed"] SEL["selection mapper - model path+offset<br/>&lt;-&gt; DOM node+offset"] end subgraph DOM["contentEditable DOM - a render target"] D["&lt;h1&gt;Title&lt;/h1&gt;<br/>&lt;p&gt;Hello &lt;strong&gt;world&lt;/strong&gt;&lt;/p&gt;<br/>&lt;ul&gt;&lt;li&gt;&lt;p&gt;one&lt;/p&gt;&lt;/li&gt;...&lt;/ul&gt;"] end M -->|"render"| V --> D D -->|"beforeinput / keydown -<br/>intent, not mutation"| CMD["command"] CMD -->|"produces"| TX["transaction<br/>(ops + inverse)"] TX -->|"apply"| M TX -->|"push"| HIST["undo stack"] D -->|"selectionchange"| SEL --> M D -->|"MutationObserver -<br/>browser changed the DOM<br/>behind our back"| REV["reconcile or revert<br/>from the model"] REV --> V style MODEL fill:#1e3f2d,stroke:#22c55e style VIEW fill:#1e3a5f,stroke:#3b82f6 style DOM fill:#0f172a,stroke:#334155 style REV fill:#3f2d1e,stroke:#f59e0b
visualized byIOCombats

The two arrows leaving the DOM are what makes this work. Input events are read as intent ("the user wants to insert 'x' at the caret") and turned into transactions against the model, rather than being allowed to mutate the DOM directly. And a MutationObserver catches the changes that slip through anyway - an extension rewriting a node, an IME committing text - so the editor can reconcile the DOM back to the model instead of silently diverging.

The document model

A tree of typed nodes, with inline formatting as marks on text rather than as nesting:

type Mark =
  | { type: 'bold' }
  | { type: 'italic' }
  | { type: 'code' }
  | { type: 'link'; href: string };

type TextNode = { type: 'text'; text: string; marks: Mark[] };

type BlockNode =
  | { type: 'paragraph'; content: TextNode[] }
  | { type: 'heading'; level: 1 | 2 | 3; content: TextNode[] }
  | { type: 'blockquote'; content: BlockNode[] }
  | { type: 'bulletList'; content: ListItemNode[] }
  | { type: 'orderedList'; content: ListItemNode[] };

type ListItemNode = { type: 'listItem'; content: BlockNode[] };

type Document = { type: 'doc'; version: 1; content: BlockNode[] };

Two decisions are worth defending.

Marks instead of nested inline elements. HTML represents bold-and-italic as nesting, and <b><i>x</i></b> and <i><b>x</b></i> are different trees with identical meaning. A marks array makes them the same value, which removes an entire class of "why did toggling bold twice change the structure" bugs and makes comparison trivial.

An explicit schema. Knowing that bulletList contains only listItem, and listItem contains blocks, lets the editor reject invalid transactions rather than producing a document no renderer can handle. A schema is what makes the model a contract instead of a convention - and it is what lets you validate a document arriving from an API you do not fully trust.

A version field, because the format will change. Migrating stored documents is inevitable, and a version number is the cheapest possible insurance.

Serialise to JSON, not HTML. JSON round-trips exactly, is queryable server-side, and does not tempt anyone into rendering it with innerHTML.

Selection and Range

The caret is the hardest part of an editor, and it is hard for one specific reason: DOM positions and model positions are different coordinate systems, and every render invalidates the DOM ones.

The browser exposes the caret through window.getSelection(), which returns a Selection containing Range objects. A Range is (startContainer, startOffset, endContainer, endOffset), where containers are DOM nodes - usually text nodes - and offsets are character indices within them. A collapsed range (start equals end) is a caret; an expanded one is a selection.

Those coordinates are useless to the model. So the editor keeps its own:

/** Path from the document root through child indices, plus a text offset. */
type ModelPosition = { path: number[]; offset: number };
type ModelSelection = { anchor: ModelPosition; head: ModelPosition };

{ path: [1, 0], offset: 5 } means "block 1, its first text node, after character 5". This survives re-rendering because it refers to the model, not to DOM nodes that will be replaced.

Two conversions must be exact:

/** DOM -> model. Called on selectionchange. */
function readSelection(root: HTMLElement): ModelSelection | null {
  const selection = window.getSelection();
  if (!selection || selection.rangeCount === 0) return null;

  const range = selection.getRangeAt(0);
  if (!root.contains(range.startContainer)) return null;

  return {
    anchor: toModelPosition(root, range.startContainer, range.startOffset),
    head: toModelPosition(root, range.endContainer, range.endOffset),
  };
}

function toModelPosition(
  root: HTMLElement,
  node: Node,
  offset: number,
): ModelPosition {
  const path: number[] = [];
  let current: Node | null = node;

  // Walk up to the root, recording the child index at each level. Data
  // attributes written during render are what tie DOM nodes back to the model.
  while (current && current !== root) {
    const parent: Node | null = current.parentNode;
    if (!parent) break;
    path.unshift(Array.prototype.indexOf.call(parent.childNodes, current));
    current = parent;
  }

  return { path, offset };
}
/** Model -> DOM. Called after every render that changed content. */
function writeSelection(root: HTMLElement, modelSelection: ModelSelection) {
  const anchor = toDomPoint(root, modelSelection.anchor);
  const head = toDomPoint(root, modelSelection.head);
  if (!anchor || !head) return;

  const range = document.createRange();
  range.setStart(anchor.node, anchor.offset);
  range.setEnd(head.node, head.offset);

  const selection = window.getSelection();
  selection?.removeAllRanges();
  selection?.addRange(range);
}

Four failure modes to know, because each is a bug report you will otherwise receive:

  • Re-render destroys the caret. Replacing a text node discards the selection pointing into it. Every content render must be followed by writeSelection, in the same frame, before the browser paints - otherwise the caret visibly jumps.
  • Offsets past the end throw. setStart with an offset beyond the node's length raises IndexSizeError. Clamp to node.textContent.length, always.
  • Zero-width boundaries are ambiguous. With adjacent nodes <strong>bold</strong>|<em>italic</em>, there are two DOM positions for one visual caret, and they imply different formatting for the next character typed. This is why editors carry "stored marks" - the formatting the next insertion should take - as explicit state rather than deriving it from position.
  • Shadow DOM and IME composition produce selections that do not map cleanly, which is why composition events must be handled as a distinct mode rather than as ordinary input.

Commands as Transactions

A command expresses intent and returns a description of the change. It does not mutate.

type Operation =
  | { kind: 'insertText'; at: ModelPosition; text: string }
  | { kind: 'deleteRange'; from: ModelPosition; to: ModelPosition; removed: TextNode[] }
  | { kind: 'addMark'; from: ModelPosition; to: ModelPosition; mark: Mark }
  | { kind: 'removeMark'; from: ModelPosition; to: ModelPosition; mark: Mark }
  | { kind: 'setBlockType'; path: number[]; from: BlockNode['type']; to: BlockNode['type'] };

type Transaction = {
  operations: Operation[];
  /** Where the caret goes after this transaction. */
  selectionAfter: ModelSelection;
  /** Set when this transaction may merge with the previous history entry. */
  groupKey?: string;
};

type Command = {
  /** Is this command meaningful for the current selection? */
  isEnabled: (state: EditorState) => boolean;
  /** Is it currently in effect? Drives the toolbar's pressed state. */
  isActive: (state: EditorState) => boolean;
  /** Produce the change, or null if it cannot apply. */
  run: (state: EditorState) => Transaction | null;
};
const toggleBold: Command = {
  isEnabled: (state) => !isSelectionInside(state, 'code'),

  isActive: (state) =>
    // "Active" means every character in the selection has the mark. A partially
    // bold selection is not active, so the button applies rather than removes.
    textNodesInSelection(state).every((node) =>
      node.marks.some((mark) => mark.type === 'bold'),
    ),

  run: (state) => {
    const { from, to } = normaliseSelection(state.selection);

    if (from.path.join() === to.path.join() && from.offset === to.offset) {
      // Collapsed selection: nothing to format yet, so record the intent for
      // the next character typed. This is why stored marks exist.
      return {
        operations: [],
        selectionAfter: state.selection,
        storedMarks: toggleMark(state.storedMarks, { type: 'bold' }),
      } as Transaction;
    }

    const kind = toggleBold.isActive(state) ? 'removeMark' : 'addMark';

    return {
      operations: [{ kind, from, to, mark: { type: 'bold' } }],
      selectionAfter: state.selection,
    };
  },
};

This shape pays for itself immediately. isEnabled disables the button when the selection is inside a code span. isActive gives the toolbar its pressed state and correctly treats a partially bold selection as not-active, so the first click bolds everything rather than unbolding half of it. The collapsed-selection branch is the mechanism behind "press Cmd-B then type" - there is nothing to format, so the intent is stored.

Because a command is pure, it is testable without a DOM, and both the toolbar button and the keyboard shortcut invoke the same code:

const KEYMAP: Record<string, Command> = {
  'Mod-b': toggleBold,
  'Mod-i': toggleItalic,
  'Mod-k': insertLink,
  'Mod-z': undoCommand,
  'Mod-Shift-z': redoCommand,
  'Mod-y': redoCommand, // Windows convention
};

function onKeyDown(event: React.KeyboardEvent) {
  const command = KEYMAP[keyName(event)];
  if (!command || !command.isEnabled(state)) return;

  event.preventDefault(); // stop the browser's own editing behaviour
  dispatch(command.run(state));
}

preventDefault is essential. Without it the browser applies its own bold on top of yours, and the DOM diverges from the model in exactly the way this architecture exists to prevent.

Prefer beforeinput over keydown for text input where available: it fires with an inputType describing the intent ("insertText", "deleteContentBackward") and is cancellable, which is a far better contract than inferring intent from key codes across keyboard layouts.

Undo and Redo

Diagram
100%
sequenceDiagram participant U as User participant V as View (contentEditable) participant C as Command layer participant M as Model participant H as History U->>V: types "h" V->>C: beforeinput (insertText "h") C->>C: build transaction<br/>groupKey = "type" C->>M: apply ops C->>H: push - merge into open<br/>"type" group (< 500ms) M->>V: render diff V->>V: writeSelection(caret after "h") U->>V: types "i" (same group) V->>C: beforeinput C->>H: merge - still one entry U->>V: Cmd-B (a different kind of action) C->>H: close the "type" group C->>M: apply addMark C->>H: push new entry U->>V: Cmd-Z V->>C: undo command C->>H: pop entry (the addMark) H->>C: inverse = removeMark C->>M: apply inverse C->>H: push entry to redo stack M->>V: render V->>V: writeSelection(selectionBefore) Note over U,H: A second Cmd-Z removes "hi" as one unit,<br/>because it was coalesced into one group.
visualized byIOCombats
type HistoryEntry = {
  transaction: Transaction;
  inverse: Operation[];
  selectionBefore: ModelSelection;
  groupKey?: string;
  committedAt: number;
};

const GROUP_WINDOW_MS = 500;

class History {
  private undoStack: HistoryEntry[] = [];
  private redoStack: HistoryEntry[] = [];

  push(transaction: Transaction, state: EditorState) {
    const previous = this.undoStack[this.undoStack.length - 1];
    const canMerge =
      previous &&
      transaction.groupKey !== undefined &&
      previous.groupKey === transaction.groupKey &&
      Date.now() - previous.committedAt < GROUP_WINDOW_MS;

    if (canMerge) {
      previous.transaction.operations.push(...transaction.operations);
      // Inverses run in reverse order, so prepend.
      previous.inverse.unshift(...invert(transaction.operations, state));
      previous.committedAt = Date.now();
    } else {
      this.undoStack.push({
        transaction,
        inverse: invert(transaction.operations, state),
        selectionBefore: state.selection,
        groupKey: transaction.groupKey,
        committedAt: Date.now(),
      });
    }

    // Any new edit invalidates the redo branch.
    this.redoStack = [];
  }

  undo(state: EditorState) {
    const entry = this.undoStack.pop();
    if (!entry) return state;

    this.redoStack.push(entry);
    // Restoring the selection is half of what makes undo feel correct - the
    // user expects the caret back where the change was made.
    return applyOperations(state, entry.inverse, entry.selectionBefore);
  }

  redo(state: EditorState) {
    const entry = this.redoStack.pop();
    if (!entry) return state;

    this.undoStack.push(entry);
    return applyOperations(
      state,
      entry.transaction.operations,
      entry.transaction.selectionAfter,
    );
  }
}

Three points that decide whether undo feels right:

Operations, not snapshots. Snapshotting the document per keystroke costs document-size times history-length. Storing operations costs change-size. For a long document that is the difference between viable and not.

Inverses must capture removed content. The inverse of a delete is an insert of exactly what was deleted, which is why deleteRange carries a removed field. Computing an inverse after the fact is impossible - the data is gone.

Grouping is a product decision. Undo after typing a sentence should remove the sentence, not one letter. So consecutive operations of the same groupKey, within a short window and adjacent in the document, coalesce; any different action closes the group. Sensible closes: applying a format, moving the caret elsewhere, pressing Enter, losing focus, and a pause longer than the window.

Finally, preventDefault on Cmd-Z. The browser has its own undo for contentEditable, and if it runs alongside yours the DOM and model desynchronise irrecoverably.

Paste Sanitisation

Paste is the editor's largest attack surface and its most common source of malformed documents. Clipboard HTML from a word processor is arbitrary markup from outside your trust boundary.

The rule: never insert pasted HTML. Parse it into your model and re-render from the model. That single decision eliminates the class of injection this component would otherwise invite.

const ALLOWED_BLOCKS: Record<string, BlockNode['type']> = {
  P: 'paragraph',
  H1: 'heading',
  H2: 'heading',
  H3: 'heading',
  BLOCKQUOTE: 'blockquote',
  UL: 'bulletList',
  OL: 'orderedList',
};

const MARK_BY_TAG: Record<string, Mark> = {
  STRONG: { type: 'bold' },
  B: { type: 'bold' },
  EM: { type: 'italic' },
  I: { type: 'italic' },
  CODE: { type: 'code' },
};

function onPaste(event: React.ClipboardEvent) {
  // Always. Letting the browser's default paste run is the whole vulnerability.
  event.preventDefault();

  const html = event.clipboardData.getData('text/html');
  const plain = event.clipboardData.getData('text/plain');

  const nodes = html
    ? parseHtmlToModel(html)
    : [{ type: 'paragraph', content: [{ type: 'text', text: plain, marks: [] }] }];

  dispatch(insertNodes(state, nodes));
}

function parseHtmlToModel(html: string): BlockNode[] {
  // An inert document: scripts do not execute, img src is not fetched, and no
  // resource loads. Never assign untrusted HTML into a live element.
  const parsed = new DOMParser().parseFromString(html, 'text/html');
  return Array.from(parsed.body.children).flatMap(convertElement);
}

function convertElement(element: Element): BlockNode[] {
  const blockType = ALLOWED_BLOCKS[element.tagName];

  // Unknown element: keep its text, discard the element itself. An allowlist
  // means anything new or unexpected is dropped by default.
  if (!blockType) {
    const text = element.textContent?.trim();
    return text
      ? [{ type: 'paragraph', content: [{ type: 'text', text, marks: [] }] }]
      : [];
  }

  if (blockType === 'heading') {
    return [{
      type: 'heading',
      level: Number(element.tagName[1]) as 1 | 2 | 3,
      content: collectInline(element, []),
    }];
  }

  return [{ type: blockType, content: collectInline(element, []) } as BlockNode];
}

function collectInline(node: Node, inherited: Mark[]): TextNode[] {
  if (node.nodeType === Node.TEXT_NODE) {
    const text = node.textContent ?? '';
    return text ? [{ type: 'text', text, marks: inherited }] : [];
  }

  if (node.nodeType !== Node.ELEMENT_NODE) return [];

  const element = node as Element;
  const mark = MARK_BY_TAG[element.tagName];
  let marks = mark ? [...inherited, mark] : inherited;

  if (element.tagName === 'A') {
    const href = element.getAttribute('href') ?? '';
    // Scheme allowlist. javascript: and data: URLs in an href are executable.
    if (/^(https?:|mailto:)/i.test(href)) {
      marks = [...marks, { type: 'link', href }];
    }
  }

  return Array.from(element.childNodes).flatMap((child) =>
    collectInline(child, marks),
  );
}

Four properties make this safe rather than merely tidy:

  1. DOMParser produces an inert document. Scripts do not run, img sources are not fetched, no resources load. Assigning untrusted HTML to a live element's innerHTML - even a detached one - is a different and riskier operation.
  2. Allowlist, not blocklist. Anything not explicitly mapped is dropped. A blocklist is a promise to have thought of every dangerous element, which nobody can keep.
  3. Style and class attributes are never read. Not one line above consults them. Pasted content adopts the document's own typography, which is both a security property and the reason pasting from Word does not import a foreign design.
  4. Link hrefs are scheme-checked. javascript: and data: in an href are executable, and a link is the one place a URL from the clipboard reaches an attribute the browser will act on.

Two extras users expect: Cmd-Shift-V for plain text, ignoring the HTML flavour entirely; and paste transforms, where a pasted URL becomes a link on the selected text rather than raw text. Keep the sanitiser as a pure function - it is the single highest-value unit test in the editor, and the input space is exactly the kind that fuzzing catches. The reasoning behind untrusted-input handling and why sanitisation belongs at a parse boundary is in Security Architecture.

Accessibility

An editing surface has real obligations, and most hand-rolled editors miss them.

<div
  role='textbox'
  aria-multiline='true'
  aria-label='Post body'
  aria-describedby='editor-help'
  contentEditable
  suppressContentEditableWarning
  spellCheck
  onBeforeInput={onBeforeInput}
  onKeyDown={onKeyDown}
  onPaste={onPaste}
/>

{/* A toolbar, so arrow keys move between buttons and Tab leaves the group. */}
<div role='toolbar' aria-label='Formatting' aria-controls='editor-body'>
  <button
    type='button'
    aria-pressed={toggleBold.isActive(state)}
    disabled={!toggleBold.isEnabled(state)}
    aria-keyshortcuts='Control+B'
    onMouseDown={(event) => event.preventDefault()} // keep focus in the editor
    onClick={() => dispatch(toggleBold.run(state))}>
    <Bold aria-hidden className='h-4 w-4' />
    <span className='sr-only'>Bold</span>
  </button>
</div>

Four requirements:

  • onMouseDown prevented on toolbar buttons. Otherwise clicking a button moves focus out of the editor, collapsing the selection before the command runs - the classic "bold does nothing when I click the button" bug.
  • role='toolbar' so the button group is one tab stop with arrow-key navigation, rather than a dozen tab stops between the user and their text.
  • aria-pressed on toggles, so state is announced rather than only shown by colour.
  • Shortcuts advertised with aria-keyshortcuts, and described in a visible help region, because a formatting shortcut nobody can discover is not a feature.

The subtler point: a screen reader in a contentEditable region relies on the browser's accessibility tree, and aggressive DOM replacement can interrupt announcements mid-word. This is another reason the reconciler should patch minimally rather than replace subtrees - a performance optimisation that is also an accessibility one. The general reasoning is in Accessibility Architecture.

Why Production Editors Use a Library

Everything above is the foundation. A shippable editor also needs:

  • IME support. Languages composed through an input method editor produce intermediate composition text that must not be treated as normal input. Getting this wrong makes the editor unusable for a large fraction of the world, and it cannot be discovered by testing in English.
  • A MutationObserver reconciliation layer. Extensions, autocorrect and the browser itself will mutate the DOM behind your back. Detecting and reverting those changes from the model is what keeps the two in sync.
  • Caret mapping across every browser quirk - the zero-width boundary cases, bidirectional text, <br> versus empty block, Safari's selection behaviour on triple-click.
  • Position mapping for collaboration. When a remote edit arrives, every local position must be rebased through it. Without that machinery, collaborative editing is not reachable - see Collaborative Document.
  • Decorations. Search highlights, spellcheck squiggles and collaborator cursors must render as overlays without becoming part of the document.

ProseMirror is the most rigorous of the three, with an explicit schema and a transform system built for collaboration. Lexical is lighter and faster to adopt, with strong accessibility work. Slate is the most React-native and the most flexible, at the cost of leaving more decisions to you. All three implement the architecture above; choosing one is declining to re-solve a problem whose full shape is already known.

Common Interview Follow-Up Questions

"The document is 50,000 words and typing lags. Why, and what do you fix?" Almost certainly a full re-render per keystroke. The fixes, in order: reconcile at block granularity so a keystroke re-renders one paragraph rather than the document, keyed by stable block ids; keep the transaction cheap by making operations describe minimal changes rather than replacing block content; virtualise blocks that are far offscreen, using the windowing technique in Virtualized List - though an editor complicates it, because the caret can be anywhere and offscreen blocks must still be reachable by Cmd-A and by find; and debounce derived work such as word count, outline extraction and autosave, none of which needs to run per keystroke.

"How do you autosave without losing work?" Debounce serialisation, not the capture. Every transaction goes into a durable local queue immediately - IndexedDB, not memory - then a debounced writer flushes to the server every few seconds or on idle. Send the operation log rather than the whole document when the backend supports it, so a save is proportional to the edit. Keep a client-side document version so the server can reject a save built on a stale base instead of clobbering someone else's write, and surface an explicit "unsaved changes" state rather than implying everything is fine. The queue-and-replay machinery is exactly what Offline and PWA Architecture describes, and an editor is the case where it is least optional, because the data being lost is something a human just wrote.

"How would you add collaborative editing?" You would not bolt it on; you would check that the model can support it. It needs stable positions that survive remote edits, which means position mapping through concurrent transactions, and either a central transform server (OT) or a conflict-free data type (CRDT). This is the single strongest argument for the model-and-transaction architecture: an editor that mutates the DOM has no representation of "what changed" to send, transform or merge. The full comparison is in Collaborative Document.

"A user pastes content and the styling looks wrong. Where do you look?" The parser, and usually it is behaving correctly. If pasted text arrives unstyled, that is the design working - style and class are deliberately ignored so content adopts the document's typography. If structure is lost, the source used elements outside the allowlist - Google Docs wraps everything in <span> with styles and expresses bold as font-weight: 700, so a tag-only parser sees plain text. Handling that specific case means reading a narrow set of computed style properties for known sources, which is a deliberate, tested exception to the "never read styles" rule rather than an abandonment of it.

"How do you test an editor?" Model and commands are pure, and that is where the depth belongs: apply a transaction, assert the resulting document and selection, then assert that applying the inverse restores the original exactly - a property worth running over generated operation sequences. The sanitiser gets a corpus of real clipboard payloads from Word, Google Docs and a plain website, plus adversarial inputs. Undo grouping is a state-machine test with fake timers. Then a small number of real-browser tests for what only exists there - caret position after typing at a mark boundary, paste, IME composition, and toolbar clicks not stealing the selection. The layering argument is in Testing Strategy.

Tradeoffs Table

OptionProsConsWhen to Use
contentEditable as the modelAlmost no code, browser handles caret and inputBrowser-dependent markup, unserialisable format, unfixable paste behaviour, no undo controlNever for a product surface; acceptable for a throwaway comment box
Structured model, DOM as render targetPredictable format, testable commands, real undo, collaboration reachableCaret mapping, reconciliation and input handling are all yoursAny editor whose output is stored, transformed or shared
Marks array for inline formattingOrder-independent, comparable, no nesting ambiguityNeeds conversion at both HTML boundariesDefault for inline formatting
Snapshot-based undoTrivial to implement, impossible to get wrongMemory scales with document size times history lengthSmall documents, or a short fixed history
Operation-based undo with groupingMemory scales with change size, groups match user expectationEvery operation needs a correct inverse; grouping is fiddlyAny editor with documents of real length
Build on ProseMirror / Lexical / SlateIME, reconciliation, decorations, collaboration-ready positions all solvedBundle cost and a real learning curve; you inherit its modelAny production editor. Hand-roll only to learn, or for a genuinely trivial surface

Where This Applies

A rich text editor is the clearest case for the single-owner principle in State Management Architecture: the moment two representations of the document exist - a model and a DOM the browser is also editing - they diverge, and every editor bug is a symptom of that divergence. It is also the highest-risk untrusted-input surface most applications ship, which is why the parse-then-render discipline from Security Architecture is not optional here. And the toolbar-plus-textbox composite is one of the widget patterns described in Accessibility Architecture, with the added subtlety that aggressive DOM replacement can interrupt screen reader announcements.

Within this track, this article is the prerequisite for Collaborative Document, which takes the same model-and-transaction architecture and asks what happens when two people edit at once. Its autosave path is the durable queue from File Upload System applied to text, and long documents eventually need the block windowing described in Virtualized List.

Advertisement

Frequently Asked Questions

Why can the DOM inside a contentEditable not be the source of truth?

Because you do not control what goes into it. The browser decides what markup to produce when the user presses Enter, and different browsers produce different things - a div, a p, or a br. Applying bold through the legacy execCommand emits b in one engine and span with a style attribute in another. Pasting from a word processor injects arbitrary foreign markup with inline styles and class names. Autocorrect, spellcheck, input method editors and browser extensions mutate the tree without firing the events you expected. If the DOM is your model, your document format is whatever soup those systems happen to produce, so you cannot reliably serialise it, validate it, diff it, or transform it. Keeping a structured model as the truth and treating the DOM as a rendering target you overwrite inverts that relationship - the browser can do whatever it likes to the DOM, because the next render replaces it from the model.

What are the Selection and Range APIs, and what is a document position in a model-based editor?

A Selection is the browser's representation of what the user has selected or where the caret sits, obtained with window.getSelection. It contains one or more Ranges, each defined by a start container node with an offset and an end container node with an offset - so a caret is a Range whose start and end are identical. Those coordinates are DOM coordinates, which means they are meaningless to your model, and they are invalidated the moment you re-render. A model-based editor therefore keeps its own position format, typically a path from the document root to a node plus a character offset within it, and converts between the two - reading a DOM selection into a model position when the user moves the caret, and writing a model position back into a DOM Range after every render. Every editor bug where the caret jumps to the wrong place after typing is a failure in one of those two conversions.

Why is the command pattern the right shape for formatting actions?

Because it separates what the user asked for from how the document changes, which is what makes everything else possible. A command is a small function that takes the current state and returns a description of the change rather than mutating anything - so it can be tested as a pure function, disabled when it does not apply to the current selection, queried for whether it is currently active so the toolbar can show it pressed, bound to a keyboard shortcut and a button without duplication, and composed with other commands into one atomic change. Most importantly, because a command produces a transaction rather than performing an edit, it can be recorded, and a recorded transaction with a computed inverse is exactly what undo needs. An editor that mutates the DOM directly in a click handler has no path to any of that.

How does undo work through a transaction log, and why not snapshot the whole document?

Each transaction records the operations it applied and how to invert them, and the editor keeps two stacks - applying a transaction pushes it onto the undo stack and clears the redo stack, undo pops it, applies the inverse and pushes it to redo. Snapshotting the entire document per keystroke is simpler but its memory cost is proportional to document size times history length, which is untenable for a long document. Operation-based history is proportional to the size of the change instead. The subtlety is grouping - a user pressing undo after typing a sentence expects the sentence to disappear, not one letter, so consecutive same-kind operations within a short window and adjacent in the document are coalesced into one history entry, while any distinct action such as applying a format or moving the caret closes the current group.

Why do production editors use ProseMirror, Slate or Lexical rather than contentEditable directly?

Because the hard parts are not the parts you see. Beyond a model and a command layer, a usable editor needs correct input-method-editor support for languages composed through an IME, where intermediate composition text must not be treated as normal input; a mutation-observer reconciliation layer that catches and reverts changes the browser or an extension made behind your back; caret position mapping that survives every render across every browser quirk; collaborative-editing-ready position mapping so a remote change rebases local positions; and a decoration system for overlays like search highlights that must not become part of the document. Each of those is months of work and each has a long tail of browser-specific behaviour discovered only in production. Using a library is not avoiding the problem, it is declining to re-solve a problem whose full shape is known.