How would you build a rich text editor (like Medium) in React?

Advanced25 min interview
Skills tested:
contentEditable ArchitectureSelection and Range APIsUndo and Redo SystemsRich Text Data ModelingDOM and React InteroperabilityContent Sanitization and Security

Advertisement

🧩 Scenario

You're building a Medium-style rich text editor that supports: - Bold, italic, underline, and code formatting - Headings, paragraphs, and blockquotes - Hyperlinks and inline formatting controls - Undo and redo history - Keyboard shortcuts - Content persistence and export - Safe paste handling The editor must work without relying on heavyweight editor frameworks while remaining responsive and reliable for long-form content creation.

🧠 Architecture Walkthrough

contentEditable and the React Reconciliation Problem

The most fundamental challenge in building a rich text editor with React is that contentEditable allows the browser to mutate the DOM directly, while React's reconciliation algorithm assumes it owns the DOM. If React re-renders the editor div with stale innerHTML, it will overwrite the user's edits.

The demo solves this with two mechanisms: suppressContentEditableWarning silences the React warning that fires when you make a child of a controlled React tree contentEditable, and more importantly, the editor's innerHTML is never used as a React prop or state variable that drives rendering.

The editorRef holds a reference to the DOM node, and content is read from and written to ref.current.innerHTML imperatively. React only controls the wrapper div's props (style, event handlers) never its children. This means the content state variable is kept in sync with the DOM for bookkeeping (onChange callbacks, history), but it is not passed back down as a prop, which would cause the reconciliation conflict.

History as a Stack with a Pointer

The History class implements undo/redo using a pointer (index) into a fixed-size array (stack). This is more correct than a simple two-stack approach because the pointer allows redo: when you undo, the index decrements but the states above it stay in the stack until the user types something new.

The push method splices off everything above the current index before adding the new state, which destroys the redo history when the user makes a new edit exactly the behavior you expect in any undo system.

The isUndoRedoRef flag is critical: when handleUndo or handleRedo sets editorRef.current.innerHTML directly, the onInput handler fires. Without the flag, that synthetic input event would push the restored state onto the history stack again, creating an infinite loop. The ref is set to true before the DOM mutation and reset to false inside handleInput when the flag is detected.

Selection Preservation Across the Link Dialog

When the user clicks the link toolbar button, they lose their text selection because focus moves to the URL input in the dialog. To preserve the selection, handleLinkClick calls saveSelection() immediately before the dialog mounts which calls window.getSelection().getRangeAt(0).cloneRange().

The cloned range object retains the exact start and end positions in the DOM. The saved range is stored in React state (savedSelection). When the user submits the link URL, handleLinkSave calls restoreSelection(savedSelection) before calling execCommand('createLink').

Without this restore step, execCommand would have no selection to operate on and would silently do nothing. This pattern save selection on blur, restore before command is required for any toolbar action that opens a secondary UI element.

Paste Sanitization as a Security Gate

The handlePaste handler intercepts the default paste event and replaces it with a sanitized version. It reads both text/html and text/plain from the clipboard. If HTML is available, it strips <script> tags, on* event attributes, and <style> blocks before inserting it.

Without this, pasting from a malicious source could inject JavaScript or tracking styles into the editor content. The sanitization here is minimal a production editor would use a dedicated library like DOMPurify but the pattern of calling e.preventDefault() and then manually inserting the content via execCommand('insertHTML', sanitized) or execCommand('insertText', text) is the right approach.

Note that text/plain is the fallback: if the clipboard has no HTML, the plain text is inserted and receives no formatting from the paste, which is often what the user wants when pasting from an external source.

💡 Key Code Explained

class History {
  push(state) {
    this.stack.splice(this.index + 1);
    this.stack.push(state);

    if (this.stack.length > this.maxSize) {
      this.stack.shift();
    } else {
      this.index++;
    }
  }

  undo() {
    if (this.canUndo()) {
      this.index--;
      return this.stack[this.index];
    }
    return null;
  }
}

The splice(this.index + 1) call at the start of push is what implements the correct undo/redo semantic: when you type after undoing, redo history is gone. Without this line, redo would let you revisit states that are now logically unreachable.

The stack.shift() branch removes the oldest state when the stack exceeds maxSize, but note the else the index is only incremented when a shift did not happen.

If a shift happens, the index stays the same because the entire stack shifted left by one position, so the current index still points to the correct "current" state. This is easy to get wrong; incrementing unconditionally would push the index past the end of the array.

const handleInput = useCallback(() => {
  if (isUndoRedoRef.current) {
    isUndoRedoRef.current = false;
    return;
  }

  const newContent = editorRef.current.innerHTML;
  setContent(newContent);
  updateCounts(newContent);

  if (newContent !== lastContentRef.current) {
    historyRef.current.push(newContent);
    lastContentRef.current = newContent;
  }

  onChange?.(newContent);
}, [onChange, updateCounts]);

The isUndoRedoRef guard prevents the input handler from recording undo/redo operations as new history entries. When handleUndo sets editorRef.current.innerHTML, the browser fires an input event on the contentEditable element.

Without the guard, this would immediately push the restored state back onto the stack, cancelling the undo. The lastContentRef deduplication check (newContent !== lastContentRef.current) prevents consecutive identical states from clogging the history stack this matters when the user clicks the toolbar (which triggers selection changes but not content changes) or uses arrow keys.

const handleLinkSave = useCallback(
  (url, linkText) => {
    setShowLinkDialog(false);

    if (savedSelection) {
      restoreSelection(savedSelection);

      if (linkText) {
        execCommand(
          'insertHTML',
          `<a href="${url}" target="_blank">${linkText}</a>`,
        );
      } else {
        execCommand('createLink', url);
      }

      setSavedSelection(null);
      editorRef.current.focus();
    }
  },
  [savedSelection],
);

There are two paths here depending on whether the user provided custom link text. If linkText is provided, insertHTML replaces the current selection with a new <a> element. If it is not provided, createLink wraps the existing selected text in an <a> tag.

The distinction matters: createLink only works when text is already selected; insertHTML works even when the cursor is just a caret with no selection. The editorRef.current.focus() call at the end is required because restoreSelection calls selection.addRange(), but the browser does not automatically shift focus back to the editor when you do this programmatically.

Without the explicit focus(), subsequent typing would go to whichever element has focus likely the dialog's cancel button.

⚖️ Tradeoffs

ApproachProCon
contentEditable + execCommand (chosen)Zero dependencies, browser handles caret and selection, works immediatelyexecCommand is deprecated; browser inconsistencies across Chrome/Firefox/Safari; hard to serialize content reliably
contentEditable + Range API directlyMore control than execCommand, not deprecatedSignificantly more code; must handle all browser quirks manually
Slate.jsSchema-based, plugin system, controlled state, serializableLarge bundle (~100KB), API changes frequently, learning curve
Lexical (Meta)Modern API, performance-focused, extensibleNewer ecosystem, less community content, overkill for simple editors
ProseMirrorMost stable, used by Notion/Linear, collaborative-readySteep learning curve, verbose schema definitions

🎯 What Interviewers Actually Check

  • Whether you can explain why suppressContentEditableWarning is needed and what goes wrong without it the key is understanding React's ownership assumption about the DOM
  • Whether you identify the selection-loss problem when a dialog opens and describe the save/restore pattern without being prompted
  • Whether your handleInput guards against undo/redo-triggered input events the isUndoRedoRef flag is a detail most candidates skip
  • Whether you recognize that execCommand is deprecated and can name a concrete scenario where it fails (e.g., Chromium removed insertHTML for certain content types in some versions)
  • Whether you implement paste sanitization at all most junior candidates forget that pasting HTML from an external source is a XSS vector

❓ Follow-Up Questions

  1. The history stack currently stores raw innerHTML strings. For a document with 10,000 words, each undo step could be 50KB or more. How would you redesign the history system to store only diffs between states?
  2. document.execCommand is deprecated and already removed in some browser contexts. Write pseudocode showing how you would implement bold formatting using the Range API and document.createElement directly.
  3. How would you test that the undo history correctly discards redo states when the user types after undoing? What would your test setup look like?
  4. The current paste sanitizer uses a simple regex replace. What category of XSS payloads would slip through, and why is DOMPurify's DOM-based approach more robust?
  5. Your PM wants real-time collaborative editing so two users can type simultaneously. Which part of this architecture would break first, and what would you replace it with?

🎮 Live Demo

📝 Summary

Building a rich text editor without an external library surfaces three problems that most React developers have never had to solve directly: preventing React from overwriting user edits in a contentEditable element, implementing an undo/redo stack that correctly handles the redo-invalidation-on-new-edit rule, and preserving a text selection across a focus change when a dialog opens.

The demo solves each of these with a targeted mechanism imperative DOM access via refs, a pointer-based history class with splice-on-push, and cloneRange for selection serialization rather than reaching for a library abstraction. The correct approach to building this in production is to use Lexical or Slate for anything beyond a basic editor, because those libraries have already solved browser inconsistencies in execCommand, cross-browser selection quirks, and content normalization across years of real-world usage.

Understanding why those problems exist and what the low-level primitives look like is what makes a candidate credible when discussing the tradeoffs.

Frequently Asked Questions

Should I use contentEditable or a custom editor engine?

contentEditable is great for simple editors. For advanced features (collaboration, plugins, schema), use Slate/ProseMirror/Lexical.

How do you handle undo/redo?

Track snapshots of document state or use command-based history stacks.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement