Business
Jobs
  • About Us
  • Solutions
    • Job Postings
      Post your job and receive qualified candidates in 48h.
    • Candidate Assessments
      500+ technical and psychological tests, plus anti-fraud.
    • Headhunting
      Tailor-made executive search from start to finish.
    • Payroll + EOR
      Payroll dispersal and EOR across 15+ LATAM countries.
  • Pricing
  • Jobs

0

211
Views
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?

about 4 years ago · Juan Pablo Isaza
2 answers
Answer question

0

Ok, I'm not familiar with the range operation, but seems to me the problem lies in the state change.

You can use useRef or useState to fix this, let me use an object with useState for now.

  function App() {
    const [HTML, setHTML] = useState()
    const [selectionRange, setSelectionRange] = useState({ range: null })

    useLayoutEffect(() => {
        const selection = document.getSelection()
        if (selectionRange !== undefined) {
            selection.removeAllRanges()
            if (selectionRange.range) 
              selection.addRange(selectionRange.range)
        }
    })

    function inputHandler(ev) {
        selectionRange.range = document.getSelection().getRangeAt(0).cloneRange())
        setSelectionRange({ ...selectionRange })
        setHTML(ev.target.innerHTML)
    }

You can easily replace this version with a useRef, the point is to make sure the value is assigned right away before going through the setState which takes time to get your state updated to the latest value.

about 4 years ago · Juan Pablo Isaza Report

0

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
about 4 years ago · Juan Pablo Isaza Report
Answer question
Find remote jobs

Discover the new way to find a job!

Top jobs
Top job categories
Business
Post vacancy Pricing Sales
Legal
Terms and conditions Privacy policy
© 2026 PeakU Inc. All Rights Reserved.
Andres GPT
Show me some job opportunities
There's an error!