DEV Community

loading...

Solving Caret Jumping in React Inputs

Kwirke
Frontend dev specialized in not finding time to finish my side projects.
・3 min read

There are many explanations out there about unwanted caret jumping in React inputs, but I couldn't find any that addressed the issue we found.

This might be an easy way to encounter the issue in complex apps, so I thought I'd add my grain of sand.

The inciting situation - async update on each keystroke

We have a controlled input that gets its value from a context that is updated asynchronously, and for this input in particular, it is updated per keystroke, not after blur.

This makes the input receive a possibly updated value every keystroke. If you have a caret in a middle position and the value changes unexpectedly, the input element won't make any assumption on the caret position, and will jump it to the end.

The problem we faced is that it also jumps when the value hasn't changed at all, except for the new character just typed.

Note that this flow might be necessary in some cases, but it is generally a bad idea. As a general rule, do not change the input value asynchronously while the user is typing.

Why does the caret jump

When you inject programmatically a different value in a DOM input, the input makes no assumption about caret position and moves it to the end.

In a controlled input, React is always capturing the input's events and then forcing a new value into the element. So, in order to avoid the caret jumping always, React will optimise(*) the synchronous updates, but it will not be able to do anything with asynchronous updates.

(*) This may be related to React internals, which I'm not the best person to ask about. I tried reproducing the caret jump with vanilla JS, to no success. If you can explain the specific reason, please be welcome to do so in the comments!

See this React issue: https://github.com/facebook/react/issues/5386

As Dan Abramov puts it:

If you use controlled inputs, you’re expected to update the value synchronously. If you need to, for example, debounce, you can do this after updating the value. For example one might maintain two values in the state: one for the input, and one for the “debounced” value.

From the point of view of the input element, the value was hell| world with the caret at the |, then the user pressed o but the event was prevented from happening, and the next it knows is that it's receiving a new value that is different, hello world, but it could as well be good bye and it's not the input's job to compare it, so it puts the caret at the end.

How to solve it

Make always a synchronous update before sending the update up the asynchronous flow.

If we have this, assuming onChange is asynchronous:

const Broken = ({ value, onChange }) => {
  return <input value={value} onChange={(e) => onChange(e.target.value)} />;
};
Enter fullscreen mode Exit fullscreen mode

We want to allow React to do the synchronous optimisation, to tell the input "Hey, this keypress was for you, do your thing and move the caret naturally".

Then, when the asynchronous update returns, if the value has not changed, the caret will not move. If the value has changed asynchronously (from another source of truth), then the caret will jump, and that is OK (**).

(**) If it is not OK for you, you will need to store the caret position yourself and implement your own custom logic to your liking. See https://stackoverflow.com/questions/46000544/react-controlled-input-cursor-jumps

How do we do that? We put a synchronous cache between the input and the async store. For example, with local state:

const Fixed = ({ value, onChange }) => {
  const [val, setVal] = useState(value);
  const updateVal = (val) => {
    /* Make update synchronous, to avoid caret jumping when the value doesn't change asynchronously */
    setVal(val);
    /* Make the real update afterwards */
    onChange(val);
  };
  return <input value={val} onChange={(e) => updateVal(e.target.value)} />;
};
Enter fullscreen mode Exit fullscreen mode

And that's it. You can find the full example code here:

https://codesandbox.io/s/react-caret-jump-3huvm?file=/src/App.js

Discussion (0)