Dealing with cursor with controlled contenteditable in React

I’m trying to set up a controlled contentEditable in React. Every time i write something in the div the component re-renders, and the cursor/caret jumps back to the beginning. I’m trying to deal with this by saving the cursor in an onInput callback:

import { useState, useEffect, useRef, useLayoutEffect } from 'react'

function App() {
    const [HTML, setHTML] = useState()
    const [selectionRange, setSelectionRange] = useState()
    console.log('on rerender:', selectionRange)

    useLayoutEffect(() => {
        console.log('in layout effect', selectionRange)
        const selection = document.getSelection()
        if (selectionRange !== undefined) {
            selection.removeAllRanges()
            selection.addRange(selectionRange)
        }
    })

    function inputHandler(ev) {
        console.log('on input', document.getSelection().getRangeAt(0))
        setSelectionRange(document.getSelection().getRangeAt(0).cloneRange())
        setHTML(ev.target.innerHTML)
    }

    return (
        <>
            <div
                contentEditable
                suppressContentEditableWarning
                onInput={inputHandler}
                dangerouslySetInnerHTML={{ __html: HTML }}
            >
            </div>
            <div>html:{HTML}</div>
        </>
    )
}

export default App

This doesn’t work, the cursor is still stuck at the beginning. If I input one character in the contentEditable div, i get the output:

on input 
Range { commonAncestorContainer: #text, startContainer: #text, startOffset: 1, endContainer: #text
, endOffset: 1, collapsed: true }
on rerender: 
Range { commonAncestorContainer: #text, startContainer: #text, startOffset: 1, endContainer: #text
, endOffset: 1, collapsed: true }
in layout effect 
Range { commonAncestorContainer: div, startContainer: div, startOffset: 0, endContainer: div, endOffset: 0, collapsed: true }

Why does the value of selectionRange change in the useLayoutEffect callback, when it was correct at the start of the re-render?

Answer

When the contentEditable div is re-rendered it disappears. The Range object contains references to the children of this div (startNode, endNode properties), and when the div disappears the Range object tracks this , and resets itself to it’s parent, with zero offset.

The code below demonstrates how to deal with this if you now that the contentEditable div will only have one child. It fixes the problem where the cursor gets stuck at the beginning. What we do is to save the offset in the text, and when restoring we create a new Range object, with the newly rendered text node as startNode and our saved offset as startOffset.

import { useState, useEffect, useRef, useLayoutEffect } from 'react'

function App() {
    const [HTML, setHTML] = useState()
    const [offset, setOffset] = useState()
    const textRef = useRef()

    useLayoutEffect(() => {
        if (offset !== undefined) {
            const newRange = document.createRange()
            newRange.setStart(textRef.current.childNodes[0], offset)
            const selection = document.getSelection()
            selection.removeAllRanges()
            selection.addRange(newRange)
        }
    })

    function inputHandler(ev) {
        const range = document.getSelection().getRangeAt(0)
        setOffset(range.startOffset)
        setHTML(ev.target.innerHTML)
    }

    return (
        <>
            <div
                contentEditable
                suppressContentEditableWarning
                onInput={inputHandler}
                dangerouslySetInnerHTML={{ __html: HTML }}
                ref={textRef}
            >
            </div>
            <div>html:{HTML}</div>
        </>
    )
}

export default App