Back to blog
Profile picture of Erik Jermaniš Erik Jermaniš

Solution for caret jumping in React inputs

2023-08-08

What are we solving?

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:

CustomInput.jsx
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.

Why does this happen?

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).

Preventing jumps

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:

  1. Saving the caret position (selectionRange) to state.
  2. Updating input's selectionStart and selectionEnd values.

1. Saving the caret position

First we need to add state for storing selectionRange and then, store it in handleChange function.

CustomInput.jsx
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;

2. Updating selectionRange

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.

CustomInput.jsx
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 to useEffect. The difference is that useLayoutEffect makes sure that any state updates or logic are processed before the browser repaints the screen. By using useLayoutEffect instead of useEffect, we are preventing any potential screen flickers.

That's it! Now the caret is no longer jumping to the end.