How would you build a rich text editor (like Medium) in React?
Advertisement
🧩 Scenario
🧠 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
| Approach | Pro | Con |
|---|---|---|
| contentEditable + execCommand (chosen) | Zero dependencies, browser handles caret and selection, works immediately | execCommand is deprecated; browser inconsistencies across Chrome/Firefox/Safari; hard to serialize content reliably |
| contentEditable + Range API directly | More control than execCommand, not deprecated | Significantly more code; must handle all browser quirks manually |
| Slate.js | Schema-based, plugin system, controlled state, serializable | Large bundle (~100KB), API changes frequently, learning curve |
| Lexical (Meta) | Modern API, performance-focused, extensible | Newer ecosystem, less community content, overkill for simple editors |
| ProseMirror | Most stable, used by Notion/Linear, collaborative-ready | Steep learning curve, verbose schema definitions |
🎯 What Interviewers Actually Check
- Whether you can explain why
suppressContentEditableWarningis 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
handleInputguards against undo/redo-triggered input events theisUndoRedoRefflag is a detail most candidates skip - Whether you recognize that
execCommandis deprecated and can name a concrete scenario where it fails (e.g., Chromium removedinsertHTMLfor 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
- The history stack currently stores raw
innerHTMLstrings. 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? document.execCommandis deprecated and already removed in some browser contexts. Write pseudocode showing how you would implementboldformatting using the Range API anddocument.createElementdirectly.- 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?
- 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?
- 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.
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