DEV Community

Kwirke
Kwirke

Posted on

Solving Caret Jumping in React Inputs

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

Top comments (8)

Collapse
 
mistersingh179 profile image
Mister Singh

thank you for this good writeup and example

Collapse
 
elflaco1976 profile image
elFlaco1976

This helped me a lot to solve my problem, thanks!

Collapse
 
bstella profile image
B.stella

This helped solved my problem. Thanks a lot!

Collapse
 
hendriku profile image
Hendrik Ulbrich

Thanks for sharing!

Collapse
 
raihanshezan profile image
Raihan Shezan

I tried your codesandbox & sorry to say, but your approach is wrong. There is no need for such complex nesting of components. What you have achieved with that code can easily be done with a simple input component with custom onChange function (see my code at the end of this comment).

The problem in your code - the updateVal method of AsynchronousStateManagement does not really help if you want to modify the value in any way. For example try adding a few extra characters after the val like -

const updateVal = (val) => {
    setTimeout(() => setVal(val + 'QWERTY'), 500)
  }
Enter fullscreen mode Exit fullscreen mode

The input value still follows the saved state of it's own (inside the Fixed component), so any value update you make inside this updateVal function of AsynchronousStateManagement is just meaningless, it is lost.

And if your goal is to do some async task with the updated value, without affecting the original value or the input field, then just do that after updating the value. Why would you use 2 separate components with 2 separate state managements for that? The purpose of useState hook is to reload the dependent components after the state changes. In this case your useState in AsynchronousStateManagement is totally useless. What you are doing in that code sandbox can easily be achieved like this -

export default function Input() {
  const [val, setVal] = useState('')
  const asyncTask = (val) => {
    setTimeout(() => console.log(val), 500)
  }
  const updateVal = (val) => {
    setVal(val)
    asyncTask(val)
  }

  return <input value={val} onChange={(e) => updateVal(e.target.value)} />
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
kwirke profile image
Kwirke

Thank you for reading this and taking the time to think other solutions.

The component AsynchronousStateManagement in the example represents the rest of the system. The code outside the Input component is a simplification of the real case, which had needs that do not relate to the issue at hand.

The solution you propose breaks the component contract. We needed a component that could be passed an (async) onChange call for the rest of the system to use as required, but your component does not accept that prop. Besides, doing API calls inside what should be an agnostic Input component couples the code, making you duplicate the Input component for each field, or dumping complex API interaction logic inside it.

In conclusion: If you don't need to receive an asynchronous onChange callback in your component, you don't have this specific issue and this article can't help you.

Collapse
 
yaireo profile image
Yair Even Or • Edited

Now, question is how to do the same for contentEditable.... 🧐

Collapse
 
giacomocerquone profile image
Giacomo Cerquone

Hey there! I wrote extensively about the same subject answering your "Why does the caret jump" with a deep dive inside React internals. Hope you'll like it: giacomocerquone.com/keep-input-cur...