Let's say that we have a controled input and, on change, we are updating the input value by replacing
" "
(spaces) with -
symbols.
The component looks like this:
import { useState } from "react";
const CustomInput = () => {
const [value, setValue] = useState("");
const handleChange = (e) => {
setValue(e.target.value.replace(/ /g, "-"));
};
return <input value={value} onChange={handleChange} />;
};
export default CustomInput;
Now, if we type in Hello World, it will get formatted as Hello-World.
Let's say that we want it to say "Hello React World" instead. We position our cursor caret behind letter o, and type [space]React.
The problem is that as soon as we enter the first character, in this situation [space], the caret jumps to the end of the input value. If we just continued typing, input value would be Hello--WorldReact instead of Hello-React-World.
To solve this problem, it's necessary to understand that this has nothing to do with React. The same problem would occur if you used plain JavaScript + HTML.
The reason why this happens is because we are injecting a modified value in a DOM input (the one where we replace
" "
with -
). When that happens, the input moves caret to the end. This happens every time
you set a value to the input, whether by typing or by setting it programmatically (like we did here).
This can be fixed by saving the caret position before changing the input value, and restoring it afterwards.
We can do that in two steps:
selectionRange
) to state.selectionStart
and selectionEnd
values.First we need to add state for storing selectionRange and then, store it in handleChange
function.
import { useState } from "react";
const CustomInput = () => {
const [value, setValue] = useState("");
const [selection, setSelection] = useState(null);
const handleChange = (e) => {
setValue(e.target.value.replace(/ /g, "-"));
setSelection([e.target.selectionStart, e.target.selectionEnd]);
};
return <input value={value} onChange={handleChange} />;
};
export default CustomInput;
Now, we need to restore the caret position.
Firstly, add a ref
to the input, and then use it to access the input element and set it's
selectionRange
. To set the selectionRange
, we will use the useLayoutEffect
hook
from React.
import { useState, useLayoutEffect, useRef } from "react";
const CustomInput = () => {
const inputRef = useRef(null);
const [value, setValue] = useState("");
const [selection, setSelection] = useState(null);
useLayoutEffect(() => {
if (selection && inputRef.current) {
[inputRef.current.selectionStart, inputRef.current.selectionEnd] =
selection;
}
}, [selection]);
const handleChange = (e) => {
setValue(e.target.value.replace(/ /g, "-"));
setSelection([e.target.selectionStart, e.target.selectionEnd]);
};
return <input ref={inputRef} value={value} onChange={handleChange} />;
};
export default CustomInput;
useLayoutEffect
is very similar touseEffect
. The difference is thatuseLayoutEffect
makes sure that any state updates or logic are processed before the browser repaints the screen. By usinguseLayoutEffect
instead ofuseEffect
, we are preventing any potential screen flickers.
That's it! Now the caret is no longer jumping to the end.