DEV Community

Typelets Team
Typelets Team

Posted on

Building a Native Rich Text Editor for React Native

We needed a rich text editor for our cross-platform note-taking app. The web version used TipTap, and we wanted the mobile version to feel native while keeping content perfectly synced. After trying every React Native editor library out there, we built our own. Here's why and how.

The Problem

When we started building Typelets' mobile app, we assumed we'd just plug in an existing rich text editor library. We were wrong.

What we tried:

  • react-native-pell-rich-editor - Laggy, inconsistent formatting
  • react-native-cn-quill - Heavy bundle, sync issues with TipTap HTML
  • @10play/tentap-editor - Promising, but we needed more control over the HTML output
  • Various markdown editors - Not the UX we wanted

The core issues:

  1. Performance - Most editors felt sluggish, especially with longer documents
  2. HTML Compatibility - Output didn't match TipTap's structure, causing sync nightmares
  3. Customization - We needed specific behaviors (task lists, code blocks) that were hard to customize

We needed an editor that:

  • Felt native on mobile
  • Produced HTML identical to TipTap's output
  • Gave us full control over keyboard behavior
  • Handled task lists, code blocks, and nested lists correctly

The Architecture Decision

Instead of fighting with libraries, we built a WebView bridge pattern:

┌─────────────────────────────────────────────────┐
│              React Native Layer                 │
│  ┌─────────────┐  ┌─────────────────────────┐   │
│  │   Toolbar   │  │    Editor Component     │   │
│  │  (Native)   │  │     (WebView Host)      │   │
│  └─────────────┘  └─────────────────────────┘   │
│         │                    │                  │
│         │    postMessage()   │                  │
│         ▼                    ▼                  │
├─────────────────────────────────────────────────┤
│                WebView Layer                    │
│  ┌─────────────────────────────────────────┐    │
│  │     contenteditable div + JavaScript    │    │
│  │     (Custom selection, formatting,      │    │
│  │      keyboard handlers)                 │    │
│  └─────────────────────────────────────────┘    │
└─────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Why this works:

  • Native toolbar = snappy UI
  • WebView editor = full control over HTML
  • Message passing = clean separation of concerns
  • Same HTML format = perfect TipTap compatibility

The Implementation

1. The Bridge Protocol

Communication happens via JSON messages between React Native and the WebView:

// WebView → React Native
interface EditorMessage {
  type: 'content' | 'formats' | 'height';
  html?: string;
  formats?: FormatState;
  height?: number;
}

// React Native → WebView (via injectJavaScript)
window.execCommand('bold');
window.execCommand('heading', 2);
window.setContent('<p>Initial content</p>');
Enter fullscreen mode Exit fullscreen mode

2. The Core Editor Component

Click to see the full Editor component
// Editor.tsx (simplified)
export const Editor = ({
  initialContent,
  onChange,
  colors
}: EditorProps) => {
  const webViewRef = useRef<WebView>(null);
  const [formats, setFormats] = useState<FormatState>({});

  const handleMessage = (event: WebViewMessageEvent) => {
    const data = JSON.parse(event.nativeEvent.data);

    switch (data.type) {
      case 'content':
        onChange?.(data.html);
        break;
      case 'formats':
        setFormats(data.formats);
        break;
      case 'height':
        // Adjust WebView height dynamically
        break;
    }
  };

  const execCommand = (command: string, value?: string) => {
    webViewRef.current?.injectJavaScript(
      `window.execCommand('${command}'${value ? `, '${value}'` : ''});`
    );
  };

  return (
    <>
      <Toolbar formats={formats} onCommand={execCommand} />
      <WebView
        ref={webViewRef}
        source={{ html: generateEditorHTML(initialContent, colors) }}
        onMessage={handleMessage}
        keyboardDisplayRequiresUserAction={false}
      />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

3. The WebView HTML (The Secret Sauce)

This is where the magic happens. We generate HTML that includes:

  • A contenteditable div
  • Custom keyboard handlers
  • Selection tracking
  • Format detection

Click to see the WebView JavaScript
// Inside the WebView
const editor = document.getElementById('editor');

// Track selection changes to update toolbar
document.addEventListener('selectionchange', () => {
  const formats = {
    bold: document.queryCommandState('bold'),
    italic: document.queryCommandState('italic'),
    underline: document.queryCommandState('underline'),
    // Custom detection for block elements
    heading: detectHeadingLevel(),
    bulletList: isInsideElement('UL:not([data-type])'),
    orderedList: isInsideElement('OL'),
    taskList: isInsideElement('[data-type="taskList"]'),
    codeBlock: isInsideElement('PRE'),
    blockquote: isInsideElement('BLOCKQUOTE'),
  };

  window.ReactNativeWebView.postMessage(
    JSON.stringify({ type: 'formats', formats })
  );
});

// Content changes
editor.addEventListener('input', () => {
  window.ReactNativeWebView.postMessage(
    JSON.stringify({ type: 'content', html: editor.innerHTML })
  );
});
Enter fullscreen mode Exit fullscreen mode

4. Custom Keyboard Handling (The Hard Part)

The biggest challenge was making Enter and Backspace behave correctly in different contexts. TipTap has specific behaviors for lists, code blocks, and task items. We had to replicate them:

Click to see keyboard handling code
editor.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') {
    e.preventDefault();

    const context = getSelectionContext();

    if (context.inCodeBlock) {
      // Insert literal newline, don't create new paragraph
      insertTextAtCursor('\n');
    }
    else if (context.inTaskList) {
      // Create new task item with same structure
      createNewTaskItem();
    }
    else if (context.inList) {
      // Create new list item, or exit list if empty
      if (context.isEmpty) {
        exitList();
      } else {
        createNewListItem();
      }
    }
    else {
      // Normal paragraph
      document.execCommand('insertParagraph');
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

5. TipTap-Compatible HTML Structure

This is crucial. TipTap expects specific HTML structures. If your mobile editor generates different HTML, you'll have sync issues.

Task Lists (TipTap format):

<ul data-type="taskList">
  <li data-type="taskItem" data-checked="false">
    <label>
      <input type="checkbox">
      <span></span>
    </label>
    <div><p>Task text here</p></div>
  </li>
</ul>
Enter fullscreen mode Exit fullscreen mode

List Items (TipTap wraps content in <p>):

<ul>
  <li><p>Item one</p></li>
  <li><p>Item two</p></li>
</ul>
Enter fullscreen mode Exit fullscreen mode

Code Blocks:

<pre><code class="language-javascript">const x = 1;</code></pre>
Enter fullscreen mode Exit fullscreen mode

We had to ensure every operation in our mobile editor produces this exact structure.

The Web Side (TipTap)

On web, we use TipTap with custom extensions:

// editor-config.ts
export const createEditorExtensions = () => [
  StarterKit.configure({
    heading: { levels: [1, 2, 3] },
    codeBlock: false, // We use custom code block
  }),
  TaskList,
  TaskItem.configure({ nested: true }),
  CodeBlockLowlight.configure({ lowlight }),
  Underline,
  Link,
  Highlight,
  // Custom extensions
  ExecutableCodeBlock,
  ResizableImage,
  SlashCommands,
];
Enter fullscreen mode Exit fullscreen mode

The key insight: both editors output the same HTML format. No conversion needed.

Syncing Content

Because both platforms produce identical HTML, syncing is trivial:

// Shared Note type
interface Note {
  id: string;
  title: string;
  content: string; // HTML - same format everywhere
  updatedAt: Date;
}
Enter fullscreen mode Exit fullscreen mode

Challenges We Solved

1. Dynamic Height

WebViews have fixed height by default. We needed the editor to grow with content:

// In WebView
const reportHeight = () => {
  const height = document.documentElement.scrollHeight;
  window.ReactNativeWebView.postMessage(
    JSON.stringify({ type: 'height', height })
  );
};

new MutationObserver(reportHeight).observe(editor, {
  childList: true,
  subtree: true,
  characterData: true,
});
Enter fullscreen mode Exit fullscreen mode

2. Theme Support

We pass theme colors from React Native into the WebView:

const generateEditorHTML = (content: string, colors: EditorColors) => `
  <style>
    body {
      background: ${colors.background};
      color: ${colors.foreground};
    }
    blockquote {
      border-left: 3px solid ${colors.blockquoteBorder};
    }
  </style>
  <div id="editor" contenteditable="true">${content}</div>
`;
Enter fullscreen mode Exit fullscreen mode

3. Keyboard Dismissal

On iOS, the keyboard doesn't dismiss easily with WebView. We added a "Done" button:

webViewRef.current?.injectJavaScript('document.activeElement.blur();');
Keyboard.dismiss();
Enter fullscreen mode Exit fullscreen mode

4. Selection Preservation

When switching between apps or screens, selection can be lost:

let savedSelection = null;

window.saveSelection = () => {
  const sel = window.getSelection();
  if (sel.rangeCount > 0) {
    savedSelection = sel.getRangeAt(0).cloneRange();
  }
};

window.restoreSelection = () => {
  if (savedSelection) {
    const sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(savedSelection);
  }
};
Enter fullscreen mode Exit fullscreen mode

Performance Results

After switching to this architecture:

Metric Result
Input latency ~16ms (feels native)
Content sync Instant (same format)
Bundle size Smaller than Quill-based solutions
Large documents Handles 10k+ words smoothly

Lessons Learned

  1. Don't fight the platform - WebView for rich text is fine. It's what every rich text editor uses anyway.

  2. HTML is a great interchange format - Instead of trying to use the same library everywhere, use the same data format.

  3. Keyboard handling is 80% of the work - Getting Enter/Backspace right in different contexts took longer than everything else combined.

  4. Test on real devices - Simulator behavior differs from real devices, especially for keyboard and selection.

  5. Copy TipTap's HTML exactly - Read TipTap's source to understand exactly what HTML it produces. Match it.

Should You Do This?

Yes, if:

  • You need perfect compatibility with a web editor
  • You want full control over behavior
  • Performance matters to your users
  • You have complex formatting needs (task lists, code blocks, etc.)

No, if:

  • Basic formatting is enough
  • You can live with some HTML differences
  • You don't have time to handle edge cases

Resources

TipTap Documentation

react-native-webview on GitHub


Building a rich text editor is hard. Building one that works across platforms is harder. But with the right architecture, it's absolutely doable.

The key insight is that you don't need the same library everywhere - you need the same data format.

Happy coding!


We built this for Typelets, a cross-platform note-taking app. If you're working on something similar, feel free to reach out!

Check out Typelets

Top comments (0)