Framework Integration

Integrating model-based contenteditable editors with React, Vue, Svelte, and other frameworks.

Overview

Integrating a model-based editor with frameworks requires careful handling of lifecycle, state management, and DOM updates. The key is to let the editor manage its own DOM while integrating with framework state.

Key principles:

  • Editor manages its own DOM - don't let framework re-render it
  • Use refs to access editor instance
  • Sync editor state with framework state when needed
  • Handle lifecycle properly - initialize and cleanup
  • Prevent framework from diffing editor DOM

React Integration

React integration requires using refs and preventing React from re-rendering the editor DOM:

Basic Integration

import { useEffect, useRef, useState } from 'react';
import { Editor } from './editor';

function EditorComponent() {
  const editorRef = useRef<HTMLDivElement>(null);
  const editorInstanceRef = useRef<Editor | null>(null);
  const [content, setContent] = useState('');

  useEffect(() => {
    if (!editorRef.current) return;

    // Initialize editor
    const editor = new Editor({
      element: editorRef.current,
      initialContent: content,
    });

    editorInstanceRef.current = editor;

    // Listen to changes
    editor.on('change', (newContent) => {
      setContent(newContent);
    });

    // Cleanup
    return () => {
      editor.destroy();
    };
  }, []); // Only run once

  // Update editor when content prop changes (from parent)
  useEffect(() => {
    if (editorInstanceRef.current && content !== editorInstanceRef.current.getContent()) {
      editorInstanceRef.current.setContent(content);
    }
  }, [content]);

  return (
    <div
      ref={editorRef}
      contentEditable={false} // Let editor manage contenteditable
      suppressContentEditableWarning // React warning suppression
    />
  );
}

Custom Hook

import { useEffect, useRef, useState, useCallback } from 'react';
import { Editor } from './editor';

function useEditor(initialContent: string = '') {
  const editorRef = useRef<HTMLDivElement>(null);
  const editorInstanceRef = useRef<Editor | null>(null);
  const [content, setContent] = useState(initialContent);
  const [isReady, setIsReady] = useState(false);

  useEffect(() => {
    if (!editorRef.current) return;

    const editor = new Editor({
      element: editorRef.current,
      initialContent,
    });

    editorInstanceRef.current = editor;
    setIsReady(true);

    editor.on('change', (newContent) => {
      setContent(newContent);
    });

    return () => {
      editor.destroy();
      setIsReady(false);
    };
  }, []);

  const setEditorContent = useCallback((newContent: string) => {
    if (editorInstanceRef.current) {
      editorInstanceRef.current.setContent(newContent);
      setContent(newContent);
    }
  }, []);

  const getEditorContent = useCallback(() => {
    return editorInstanceRef.current?.getContent() || '';
  }, []);

  return {
    editorRef,
    content,
    isReady,
    setContent: setEditorContent,
    getContent: getEditorContent,
    editor: editorInstanceRef.current,
  };
}

// Usage
function MyEditor() {
  const { editorRef, content, isReady, setContent } = useEditor('');

  return (
    <div>
      <div ref={editorRef} suppressContentEditableWarning />
      {isReady && <p>Editor is ready. Content: {content}</p>}
    </div>
  );
}

Preventing Re-renders

import { memo, useRef, useEffect } from 'react';

// Memoize to prevent unnecessary re-renders
const EditorComponent = memo(({ initialContent }: { initialContent: string }) => {
  const editorRef = useRef<HTMLDivElement>(null);
  const editorInstanceRef = useRef<Editor | null>(null);

  useEffect(() => {
    if (!editorRef.current) return;

    const editor = new Editor({
      element: editorRef.current,
      initialContent,
    });

    editorInstanceRef.current = editor;

    return () => {
      editor.destroy();
    };
  }, []); // Empty deps - only initialize once

  // Use data attribute to prevent React from diffing
  return (
    <div
      ref={editorRef}
      data-editor-root
      suppressContentEditableWarning
      // Prevent React from updating this element
      dangerouslySetInnerHTML={{ __html: '' }}
    />
  );
});

// Or use shouldComponentUpdate equivalent
function EditorWrapper({ content }: { content: string }) {
  const editorRef = useRef<HTMLDivElement>(null);
  const editorInstanceRef = useRef<Editor | null>(null);

  useEffect(() => {
    if (!editorRef.current) return;

    const editor = new Editor({
      element: editorRef.current,
    });

    editorInstanceRef.current = editor;

    return () => editor.destroy();
  }, []);

  // Only update if content actually changed
  useEffect(() => {
    if (editorInstanceRef.current) {
      const current = editorInstanceRef.current.getContent();
      if (current !== content) {
        editorInstanceRef.current.setContent(content);
      }
    }
  }, [content]);

  return <div ref={editorRef} suppressContentEditableWarning />;
}

Vue Integration

Vue integration uses template refs and lifecycle hooks:

Composition API

<template>
  <div ref="editorRef" />
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from "vue";
import { Editor } from './editor';

const editorRef = ref<HTMLElement | null>(null);
const editorInstance = ref<Editor | null>(null);
const content = ref('');

onMounted(() => {
  if (!editorRef.value) return;

  const editor = new Editor({
    element: editorRef.value,
    initialContent: content.value,
  });

  editorInstance.value = editor;

  editor.on('change', (newContent) => {
    content.value = newContent;
  });
});

onUnmounted(() => {
  editorInstance.value?.destroy();
});

// Watch for external content changes
watch(() => props.content, (newContent) => {
  if (editorInstance.value && newContent !== editorInstance.value.getContent()) {
    editorInstance.value.setContent(newContent);
  }
});
</script>

Options API

<template>
  <div ref="editor" />
</template>

<script>
import { Editor } from './editor';

export default {
  data() {
    return {
      editorInstance: null,
      content: '',
    };
  },
  mounted() {
    if (!this.$refs.editor) return;

    this.editorInstance = new Editor({
      element: this.$refs.editor,
      initialContent: this.content,
    });

    this.editorInstance.on('change', (newContent) => {
      this.content = newContent;
    });
  },
  beforeUnmount() {
    if (this.editorInstance) {
      this.editorInstance.destroy();
    }
  },
  watch: {
    // Watch for prop changes
    initialContent(newContent) {
      if (this.editorInstance && newContent !== this.editorInstance.getContent()) {
        this.editorInstance.setContent(newContent);
      }
    },
  },
};
</script>

Svelte Integration

Svelte integration uses bind:this and lifecycle functions:

<script lang="ts">
  import { onMount, onDestroy } from "svelte";
  import { Editor } from './editor';

  let editorElement: HTMLDivElement;
  let editorInstance: Editor | null = null;
  let content = '';

  onMount(() => {
    if (!editorElement) return;

    editorInstance = new Editor({
      element: editorElement,
      initialContent: content,
    });

    editorInstance.on('change', (newContent) => {
      content = newContent;
    });
  });

  onDestroy(() => {
    editorInstance?.destroy();
  });

  // Reactive statement for prop updates
  $: if (editorInstance && $initialContent !== editorInstance.getContent()) {
    editorInstance.setContent($initialContent);
  }
</script>

<div bind:this={editorElement} />

Common Patterns

Common patterns across frameworks:

Controlled vs Uncontrolled

// Controlled: Framework manages content
function ControlledEditor({ content, onChange }: ControlledProps) {
  const editorRef = useRef<HTMLDivElement>(null);
  const editorInstanceRef = useRef<Editor | null>(null);

  useEffect(() => {
    if (!editorRef.current) return;

    const editor = new Editor({
      element: editorRef.current,
      initialContent: content,
    });

    editorInstanceRef.current = editor;

    editor.on('change', (newContent) => {
      onChange(newContent); // Notify parent
    });

    return () => editor.destroy();
  }, []);

  // Update editor when content prop changes
  useEffect(() => {
    if (editorInstanceRef.current) {
      editorInstanceRef.current.setContent(content);
    }
  }, [content]);

  return <div ref={editorRef} suppressContentEditableWarning />;
}

// Uncontrolled: Editor manages its own state
function UncontrolledEditor({ initialContent }: UncontrolledProps) {
  const editorRef = useRef<HTMLDivElement>(null);
  const editorInstanceRef = useRef<Editor | null>(null);

  useEffect(() => {
    if (!editorRef.current) return;

    const editor = new Editor({
      element: editorRef.current,
      initialContent,
    });

    editorInstanceRef.current = editor;

    return () => editor.destroy();
  }, []);

  // Expose methods via ref
  useImperativeHandle(ref, () => ({
    getContent: () => editorInstanceRef.current?.getContent() || '',
    setContent: (content: string) => editorInstanceRef.current?.setContent(content),
  }));

  return <div ref={editorRef} suppressContentEditableWarning />;
}

Preventing Framework DOM Diffing

// React: Use key to prevent re-renders
<div key="editor-root" ref={editorRef} suppressContentEditableWarning />

// React: Use data attribute
<div data-editor-root ref={editorRef} suppressContentEditableWarning />

// Vue: Use key
<div :key="'editor'" ref="editorRef" />

// Vue: Use v-once (render once)
<div v-once ref="editorRef" />

// Svelte: Use key
<div key="editor" bind:this={editorElement} />

// General: Mark element as non-reactive
<div 
  ref={editorRef}
  data-framework-ignore // Framework should ignore this element
  suppressContentEditableWarning
/>

State Management

Integrating with state management libraries:

Redux Integration

import { useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Editor } from './editor';
import { setEditorContent, selectEditorContent } from './editorSlice';

function ReduxEditor() {
  const editorRef = useRef<HTMLDivElement>(null);
  const editorInstanceRef = useRef<Editor | null>(null);
  const dispatch = useDispatch();
  const content = useSelector(selectEditorContent);

  useEffect(() => {
    if (!editorRef.current) return;

    const editor = new Editor({
      element: editorRef.current,
      initialContent: content,
    });

    editorInstanceRef.current = editor;

    editor.on('change', (newContent) => {
      dispatch(setEditorContent(newContent));
    });

    return () => editor.destroy();
  }, []);

  // Update editor when Redux state changes
  useEffect(() => {
    if (editorInstanceRef.current && content !== editorInstanceRef.current.getContent()) {
      editorInstanceRef.current.setContent(content);
    }
  }, [content]);

  return <div ref={editorRef} suppressContentEditableWarning />;
}

Zustand Integration

import { useEffect, useRef } from 'react';
import { useEditorStore } from './editorStore';
import { Editor } from './editor';

function ZustandEditor() {
  const editorRef = useRef<HTMLDivElement>(null);
  const editorInstanceRef = useRef<Editor | null>(null);
  const content = useEditorStore((state) => state.content);
  const setContent = useEditorStore((state) => state.setContent);

  useEffect(() => {
    if (!editorRef.current) return;

    const editor = new Editor({
      element: editorRef.current,
      initialContent: content,
    });

    editorInstanceRef.current = editor;

    editor.on('change', (newContent) => {
      setContent(newContent);
    });

    return () => editor.destroy();
  }, []);

  useEffect(() => {
    if (editorInstanceRef.current && content !== editorInstanceRef.current.getContent()) {
      editorInstanceRef.current.setContent(content);
    }
  }, [content]);

  return <div ref={editorRef} suppressContentEditableWarning />;
}