Using contenteditable in React

Last reviewed on November 15, 2022

In my last post, I went over how I created a contenteditable div in Ember. This lead me to want to see how I'd do it in React.

My First Implementation with a Jumping Cursor

I originally started with the following implementation, adhering to a controlled component data flow:

import { useState } from "react";

export default function App() {
  const [content, setContent] = useState("foo");

  return (
    <div
      contentEditable="true"
      onInput={event => {
        setContent(event.target.textContent);
      }}
    >
      {content}
    </div>
  );
}

The first problem is that there is the following warning in red in the console:

Warning: A component is contentEditable and contains children managed by React. It is now your responsibility to guarantee that none of those nodes are unexpectedly modified or duplicated. This is probably not intentional.

The second problem is that as a user types into the contenteditable div, the cursor jumps to the beginning of the element.

Try it here on CodeSandbox.

The Solution

To fix this, I started by creating a component called Contenteditable that can be used as follows:

import { useState } from "react";
import Contenteditable from "./Contenteditable";

export default function App() {
  const [content, setContent] = useState("foo");

  return (
    <>
      <Contenteditable
        value={content}
        onChange={updatedContent => {
          setContent(updatedContent);
        }}
      />

      <p>{content}</p>
    </>
  );
}

Here is the implementation of the Contenteditable component:

import { useEffect, useRef } from "react";

export default function Contenteditable(props) {
  const contentEditableRef = useRef(null);

  useEffect(() => {
    if (contentEditableRef.current.textContent !== props.value) {
      contentEditableRef.current.textContent = props.value;
    }
  });

  return (
    <div
      contentEditable="true"
      ref={contentEditableRef}
      onInput={event => {
        props.onChange(event.target.textContent);
      }}
    />
  );
}

Try it here on CodeSandbox.

Updating the textContent of the div causes the cursor to jump to the beginning, so to prevent this from happening on every keystroke, I conditionally assign the textContent only if it isn't the same as the value prop. The only time these two values might not be the same is on initial render where I specified an initial value of "foo" and the element's initial textContent is an empty string. Even if there isn't an initial value, and hence the values will be the same (an empty string), there won't be a problem as the cursor will just start at the beginning of the element. As a user types, these two values will be the same so the textContent won't get reassigned and thus the cursor won't jump to the beginning. Problem fixed!