Implementing a Robust Rich Text Editor in React: From contentEditable to Lexical
Step-by-step guide to implementing a React rich text editor: contentEditable basics, Slate and Lexical setups, plus security, paste, a11y, and performance.
Image used for representation purposes only.
Why build a rich text editor in React?
Whether you’re crafting a blog platform, comments with formatting, or a collaborative doc tool, rich text is a core UX feature. The challenge is balancing power with reliability: you need consistent formatting behavior, safe HTML, accessibility, performant updates, and straightforward persistence. In this guide, we’ll cover the trade‑offs, then implement three approaches:
- Bare-bones contentEditable (good for prototypes)
- A production-ready editor with Slate (JSON document model)
- A modern, extensible option with Lexical (command- and node-driven)
We’ll also discuss security, accessibility, paste handling, images, persistence, testing, and performance.
Requirements checklist
Before choosing a stack, define what “done” means:
- Formatting: bold, italic, underline, code, headings, lists, blockquote
- Links and inline styles (marks)
- Keyboard shortcuts and undo/redo
- Paste from Word/Google Docs with cleanup
- Images and embeds (optional)
- Accessibility: ARIA, focus management, screen‑reader cues
- International input (IME), RTL text
- Persistence: serialize to JSON/HTML/Markdown
- Security: XSS-safe output and input sanitation
- Performance on long documents
Option 1: contentEditable — minimal and fragile
The simplest path is a contentEditable div with a small toolbar. It’s fast to ship but easy to break: browser differences, selection edge cases, and DOM-as-source-of-truth make it hard to maintain. Use this for demos, not mission‑critical editors.
import React, { useRef, useState, useEffect } from 'react';
import DOMPurify from 'dompurify';
export function CEEditor() {
const ref = useRef<HTMLDivElement>(null);
const [html, setHtml] = useState('<p>Edit me <strong>now</strong>.</p>');
useEffect(() => {
if (ref.current) ref.current.innerHTML = html;
}, [html]);
function onInput() {
if (!ref.current) return;
const dirty = ref.current.innerHTML;
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['p','strong','em','u','a','ul','ol','li','code','pre','br','h1','h2','blockquote'],
ALLOWED_ATTR: ['href','target','rel']
});
setHtml(clean);
}
// execCommand is deprecated but works for quick prototypes
function cmd(command: string, value?: string) {
document.execCommand(command, false, value);
onInput();
ref.current?.focus();
}
return (
<div>
<div style={{ display:'flex', gap:8, marginBottom:8 }}>
<button onClick={() => cmd('bold')}>B</button>
<button onClick={() => cmd('italic')}>I</button>
<button onClick={() => cmd('underline')}>U</button>
<button onClick={() => cmd('formatBlock','H1')}>H1</button>
<button onClick={() => cmd('insertUnorderedList')}>• List</button>
<button onClick={() => cmd('createLink', prompt('URL?') || '')}>Link</button>
</div>
<div
ref={ref}
contentEditable
role="textbox"
aria-multiline="true"
onInput={onInput}
style={{ border:'1px solid #ccc', padding:12, minHeight:120 }}
/>
<pre>{html}</pre>
</div>
);
}
Notes:
- Keep DOMPurify (or a similar sanitizer) in the change pipeline.
- Expect selection quirks and inconsistent paste behavior.
- Undo/redo piggybacks on the browser; it’s not reliable across custom actions.
Option 2: Slate — document model in JSON
Slate represents the document as a JSON tree and uses React to render nodes. You get predictable transforms, custom elements/marks, and built-in history. It’s a strong default for app‑specific editors.
npm install slate slate-react slate-history
import React, { useMemo, useCallback } from 'react';
import { Slate, Editable, withReact, useSlate } from 'slate-react';
import { createEditor, Transforms, Editor, Text } from 'slate';
import { withHistory } from 'slate-history';
const initialValue = [
{ type: 'paragraph', children: [ { text: 'Hello, Slate!' } ] }
];
const HOTKEYS: Record<string,string> = {
'mod+b': 'bold',
'mod+i': 'italic',
'mod+u': 'underline',
};
function isMarkActive(editor: Editor, format: string) {
const marks = Editor.marks(editor);
return marks ? (marks as any)[format] === true : false;
}
function toggleMark(editor: Editor, format: string) {
const active = isMarkActive(editor, format);
if (active) Editor.removeMark(editor, format);
else Editor.addMark(editor, format, true);
}
function MarkButton({ format, label }: { format: string; label: string }) {
const editor = useSlate();
const active = isMarkActive(editor, format);
return (
<button
aria-pressed={active}
onMouseDown={(e) => { e.preventDefault(); toggleMark(editor, format); }}
>{label}</button>
);
}
export function SlateEditor() {
const editor = useMemo(() => withHistory(withReact(createEditor() as Editor)), []);
const renderElement = useCallback((props: any) => {
const { element, attributes, children } = props;
switch (element.type) {
case 'heading-one':
return <h1 {...attributes}>{children}</h1>;
case 'bulleted-list':
return <ul {...attributes}>{children}</ul>;
case 'list-item':
return <li {...attributes}>{children}</li>;
default:
return <p {...attributes}>{children}</p>;
}
}, []);
const renderLeaf = useCallback((props: any) => {
const { attributes, children, leaf } = props;
let el = children;
if (leaf.bold) el = <strong>{el}</strong>;
if (leaf.italic) el = <em>{el}</em>;
if (leaf.underline) el = <u>{el}</u>;
if (leaf.code) el = <code>{el}</code>;
return <span {...attributes}>{el}</span>;
}, []);
const onKeyDown = (event: React.KeyboardEvent) => {
const key = (event.ctrlKey || event.metaKey ? 'mod+' : '') + event.key.toLowerCase();
const format = HOTKEYS[key];
if (format) {
event.preventDefault();
toggleMark(editor, format);
}
};
return (
<Slate editor={editor} value={initialValue} onChange={() => {}}>
<div style={{ display:'flex', gap:8, marginBottom:8 }}>
<MarkButton format='bold' label='Bold' />
<MarkButton format='italic' label='Italic' />
<MarkButton format='underline' label='Underline' />
</div>
<Editable
renderElement={renderElement}
renderLeaf={renderLeaf}
onKeyDown={onKeyDown}
placeholder="Write something…"
style={{ border:'1px solid #ccc', padding:12, minHeight:120 }}
/>
</Slate>
);
}
Why Slate?
- React-first: it renders with your components.
- Structured value: serialize to JSON/HTML/Markdown consistently.
- Transform API: deterministic operations and history.
- Extensible: define elements, void nodes (e.g., images), normalizers.
Things to watch:
- Custom schema/normalization can be verbose but pays off in correctness.
- Large docs require memoization and minimal rerenders for leaf nodes.
Saving and loading with Slate
- Persist the JSON value as-is for fidelity.
- If you need HTML, use a serializer on save and sanitize on load.
import DOMPurify from 'dompurify';
export function sanitizeHtml(html: string) {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p','strong','em','u','a','ul','ol','li','code','pre','blockquote','h1','h2','br'],
ALLOWED_ATTR: ['href','rel','target']
});
}
Option 3: Lexical — modern, fast, and plugin-oriented
Lexical (from the Meta team) emphasizes performance and composability. It uses immutable editor state, commands, and plugins. It’s ergonomic for building complex, high‑performance editors.
npm install lexical @lexical/react
import React from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
const theme = {
text: { bold: 'font-bold', italic: 'italic', underline: 'underline' },
paragraph: 'mb-2',
};
function onChange(editorState: any) {
editorState.read(() => {
// traverse and persist serialized state, or export to HTML/Markdown
});
}
export function LexicalEditor() {
const initialConfig = {
namespace: 'my-richtext',
theme,
onError(error: Error) { console.error(error); }
};
return (
<LexicalComposer initialConfig={initialConfig}>
<div style={{ border:'1px solid #ccc', padding:12 }}>
<RichTextPlugin
contentEditable={<ContentEditable style={{ minHeight: 120, outline: 'none' }} />}
placeholder={<div style={{ opacity: 0.5 }}>Write something…</div>}
/>
<HistoryPlugin />
<ListPlugin />
<OnChangePlugin onChange={onChange} />
</div>
</LexicalComposer>
);
}
Why Lexical?
- Excellent performance on long documents
- Clear separation of core, nodes, and plugins
- Commands and updates that minimize React rerenders
Trade‑offs:
- Slightly steeper learning curve vs Slate’s “just React” feel
- You’ll rely on Lexical’s node ecosystem or write your own nodes
Choosing a library: quick guidance
- Prototype or simple input with light formatting: contentEditable + sanitizer
- App-specific, React-centric, schema you own: Slate
- Large docs, collaborative-feel performance, plugin model: Lexical
- Need a full-featured document editor or WYSIWYG with advanced typesetting: consider TipTap (on ProseMirror) as an alternative
Security: never trust HTML
- Sanitize any HTML you import or paste; sanitize on save if storing HTML.
- Avoid dangerousSetInnerHTML unless sanitized.
- Strip script tags, event handlers (on*), and inline JS URLs.
- For link creation, enforce URL protocols (http, https, mailto) and set rel=“noopener noreferrer” when target="_blank".
Paste handling and normalization
- Intercept onPaste and normalize:
- Remove unsupported tags/attributes
- Convert / to semantic /
- Flatten nested lists/blocks if not supported by your schema
- Offer “Paste as plain text” (mod+shift+v) for deterministic results
Example (React DOM onPaste skeleton):
function onPaste(e: React.ClipboardEvent) {
const html = e.clipboardData.getData('text/html');
const text = e.clipboardData.getData('text/plain');
if (html) {
e.preventDefault();
const clean = sanitizeHtml(html); // from earlier
// Insert via editor-specific API (Transforms in Slate, INSERT_HTML command in Lexical, etc.)
}
}
Images and uploads
- Represent images as void nodes (Slate) or custom nodes (Lexical)
- On drop/paste, upload to storage (e.g., S3) and swap a temporary placeholder with the final URL
- Track upload progress and handle retry/cancel
Sketch of an image node in Slate:
const ImageElement = ({ attributes, children, element }: any) => (
<div {...attributes} contentEditable={false}>
<img src={element.url} alt={element.alt || ''} style={{ maxWidth: '100%' }} />
{children}
</div>
);
Accessibility (a11y)
- Toolbar buttons: use button elements with aria-pressed for toggles
- Provide keyboard shortcuts and visible focus states
- Ensure headings and lists map to semantic HTML tags
- Announce formatting changes for screen readers (e.g., aria-live region)
- Respect IME composition events; avoid interfering during composition
Undo/redo and history
- Slate and Lexical ship history plugins out of the box
- If rolling your own, maintain a bounded stack of operations with coalescing (e.g., merge consecutive typing into one entry)
Persistence strategies
- JSON (preferred for structured editors like Slate/Lexical): exact fidelity, easy migrations
- HTML (WYSIWYG-centric): broadly compatible but sanitize aggressively
- Markdown (developer-friendly): convert on import/export; watch for lossy transforms (lists, tables)
Tip: store both JSON (source of truth) and a sanitized HTML rendition for SEO/display where necessary.
Performance tips
- Debounce expensive serializers and autosave
- Memoize renderers for leaves/elements; keep props stable
- Avoid putting editor value in React state if the library manages it internally
- Virtualize non-editable views (read-only previews) for very long content
- Prefer commands/transactions that batch DOM updates
Testing strategy
- Unit-test transforms (e.g., toggleMark, normalize) with JSON inputs/outputs
- Integration tests using user-event: typing, shortcuts, paste, selection edges
- E2E tests for uploads, large pastes, and navigation/focus traps
- Include cross‑browser checks (Chrome, Firefox, Safari) and mobile input (iOS/Android IME)
Putting it together: a pragmatic stack
- Use Lexical or Slate for most apps. Implement a compact toolbar, paste normalizer, image node, and autosave.
- Sanitize anything that crosses the boundary (import/paste/save/display).
- Treat accessibility and performance as first‑class from day one.
Conclusion
React makes it easy to compose a rich text editor UI, but correctness comes from a solid document model, safe HTML handling, and thoughtful UX. Start with concrete requirements, choose a library that matches your constraints, and build incrementally: formatting and links first, then paste cleanup, images, and autosave. With Slate or Lexical, you’ll ship an editor that’s both pleasant to use and maintainable over time.
Related Posts
Building a Robust React Toast Notification System: Patterns, Code, and UX
Design and implement a fast, accessible React toast system with clean APIs, animations, and promise toasts—complete code, patterns, and pitfalls.
Mastering React Virtualized Lists: Performance Patterns and Tuning Guide
Build fast, memory‑efficient React virtualized lists with tuning tips for overscan, variable heights, a11y, and profiling—plus practical code examples.
React Date Range Picker: Patterns, Libraries, and Production-Ready Examples
A practical guide to building robust React date range pickers with UX tips, code examples, validation, time zones, and testing strategies.