Caret position jumps to beginning on React re-render
OS: Any Any · Device: Desktop or Laptop Any · Browser: Safari Latest · Keyboard: US
Open case →Scenario
When using contentEditable elements in React, the caret (cursor) position jumps to the beginning of the element whenever the component re-renders. This occurs because React's reconciliation process replaces DOM nodes, causing the browser to lose track of the caret position. This issue is more prevalent in Safari and Firefox.
When using contentEditable elements in React, the caret (cursor) position jumps to the beginning of the element whenever the component re-renders. This occurs because React’s reconciliation process replaces DOM nodes, causing the browser to lose track of the caret position.
React’s reconciliation algorithm may replace DOM nodes when state changes, causing the browser to lose track of the caret position. Safari and Firefox handle DOM updates differently from Chrome, making them more susceptible to this issue.
Avoid controlled components and use refs to manage content:
import React, { useRef } from 'react';
function ContentEditable() {
const contentRef = useRef(null);
const handleInput = (e) => {
// Handle input without triggering re-render
const content = e.currentTarget.textContent;
// Update state only when needed (e.g., on blur)
};
return (
<div
contentEditable
ref={contentRef}
onInput={handleInput}
suppressContentEditableWarning
/>
);
}
Save caret position before updates and restore after:
import React, { useRef, useEffect } from 'react';
function ContentEditable({ value, onChange }) {
const editableRef = useRef(null);
const caretPositionRef = useRef(null);
const saveCaretPosition = () => {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
caretPositionRef.current = {
startContainer: range.startContainer,
startOffset: range.startOffset,
endContainer: range.endContainer,
endOffset: range.endOffset
};
}
};
const restoreCaretPosition = () => {
if (!caretPositionRef.current) return;
const selection = window.getSelection();
const range = document.createRange();
range.setStart(
caretPositionRef.current.startContainer,
caretPositionRef.current.startOffset
);
range.setEnd(
caretPositionRef.current.endContainer,
caretPositionRef.current.endOffset
);
selection.removeAllRanges();
selection.addRange(range);
};
useEffect(() => {
if (editableRef.current && value !== editableRef.current.textContent) {
saveCaretPosition();
editableRef.current.textContent = value;
restoreCaretPosition();
}
}, [value]);
return (
<div
contentEditable
ref={editableRef}
onInput={(e) => onChange(e.currentTarget.textContent)}
suppressContentEditableWarning
/>
);
}
Libraries that handle caret management automatically:
Use React.memo, useMemo, and useCallback to prevent unnecessary re-renders:
const ContentEditable = React.memo(({ value, onChange }) => {
// Component implementation
}, (prevProps, nextProps) => {
// Only re-render if value actually changed
return prevProps.value === nextProps.value;
});
Visual view of how this scenario connects to its concrete cases and environments. Nodes can be dragged and clicked.
Each row is a concrete case for this scenario, with a dedicated document and playground.
| Case | OS | Device | Browser | Keyboard | Status |
|---|---|---|---|---|---|
| ce-0316-react-caret-jumps-on-rerender | Any Any | Desktop or Laptop Any | Safari Latest | US | draft |
Open a case to see the detailed description and its dedicated playground.
OS: Any Any · Device: Desktop or Laptop Any · Browser: Safari Latest · Keyboard: US
Open case →Other scenarios that share similar tags or category.
When editing content inside an element with `position:relative`, the text caret (cursor) is completely invisible. Text can be typed and appears in the editor, but there's no visual feedback of where the insertion point is located.
After pasting content into a contenteditable region, the caret position does not end up at the expected location, sometimes jumping to the beginning of the pasted content or to an unexpected position.
When using contenteditable with JavaScript frameworks like Vue, Angular, or Svelte, state synchronization between the DOM and framework state can cause caret position issues, event mismatches, and performance problems. Each framework has unique challenges when integrating with contenteditable.
The beforeinput event, which is crucial for intercepting and modifying input before it's committed to the DOM, is not supported in Safari. This makes it difficult to implement custom input handling that works across all browsers.
When browser dark mode is enabled, contenteditable elements may experience invisible or poorly visible caret, inline style injection conflicts, background color issues, and form control styling problems. These issues are caused by missing color-scheme declarations and conflicts between browser-injected styles and custom CSS.
Have questions, suggestions, or want to share your experience? Join the discussion below.