Rich Text Editor Architecture
ContentEditable pitfalls, document models, and why you should use ProseMirror/TipTap not contentEditable directly.
Component PatternsAdvanced
Why
Building a rich text editor on raw contentEditable sounds simple until you hit cursor jumps, broken copy-paste, and unserializable HTML. A document-model editor (ProseMirror/TipTap/Lexical) gives you a schema, transactions, and sane behavior.
Compound component patterns are used by Radix UI, Headless UI, and shadcn/ui — the three most adopted headless libraries in 2024. They reduce prop drilling by 70% compared to flat component APIs.
When
Use a real editor for anything beyond a single-line input: comments with bold/italic, articles, docs. For code, use a code editor (Monaco/CodeMirror) instead.
How
1. Pick a framework — TipTap (ProseMirror wrapper, React-friendly) or Lexical (Meta, performant)
2. Schema — define allowed nodes/marks (paragraph, heading, bold, link); reject anything else
3. Toolbar — buttons call editor commands (toggleBold, setLink), disable when command inactive
4. Paste — sanitize HTML to your schema, strip styles/classes, keep only semantic tags
5. Persistence — serialize to JSON (not HTML) for storage; HTML is a render target, not a model
6. Collaboration — yjs or ProseMirror collab plugin for real-time multi-user
7. Extensions — image upload, mention, code block as plugins; never fork the core
Example
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
function RichEditor({ value, onChange }: { value: string; onChange: (html: string) => void }) {
const editor = useEditor({
extensions: [StarterKit], // paragraph, heading, bold, italic, list, code...
content: value,
onUpdate: ({ editor }) => onChange(editor.getHTML()),
editorProps: {
attributes: { class: "prose min-h-32 focus:outline-none" },
},
});
return (
<div className="border rounded">
<div className="toolbar">
<button onClick={() => editor?.chain().focus().toggleBold().run()}
className={editor?.isActive("bold") ? "bg-gray-200" : ""}>B</button>
<button onClick={() => editor?.chain().focus().toggleItalic().run()}>I</button>
</div>
<EditorContent editor={editor} />
</div>
);
}
Common Problems
- ⚠Storing HTML as the source of truth — can't query/transform, paste injects arbitrary tags
- ⚠No schema — users paste a whole Word doc with inline styles, layout breaks
- ⚠Cursor jumps on re-render — editor re-inits on every parent render, loses focus
Best Practices
- ✓Store JSON, render HTML — the document model is queryable and transformable; HTML is output only
- ✓Define a strict schema — reject nodes/marks you don't support, paste auto-converts to fit
- ✓Stabilize the editor instance — useEditor with a stable config, don't recreate on every render