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.

ASOasis
8 min read
Implementing a Robust Rich Text Editor in React: From contentEditable to Lexical

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